6 akser, StallGuard og basic web interface med ledningsdiagrammer tilføjet

This commit is contained in:
Stani committed 2026-10-25 20:26:33 +01:00
commit 0ea0080886
14 files changed
+2207

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
"""Web-interface til stepper-test. Kører på PC'en og taler med ESP32 over USB-serial.
Start: web/.venv/bin/python web/server.py [--port /dev/ttyUSB0]
Åbn: http://127.0.0.1:8000
"""
import argparse
import asyncio
import json
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
import serial
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
STATIC_DIR = Path(__file__).parent / "static"
HEARTBEAT_S = 0.5 # firmwarens watchdog stopper efter 2 s uden data
class SerialLink:
"""Én seriel forbindelse til ESP32. Genforbinder automatisk hvis kablet ryger."""
def __init__(self, port: str, baud: int, on_message):
self.port = port
self.baud = baud
self.on_message = on_message # kaldes fra læsetråden med en dict
self.ser: serial.Serial | None = None
self.lock = threading.Lock()
self.connected = False
self.error = ""
def start(self):
threading.Thread(target=self._run, daemon=True).start()
def _set_state(self, connected: bool, error: str = ""):
if connected != self.connected or error != self.error:
self.connected, self.error = connected, error
self.on_message({"type": "conn", "connected": connected, "port": self.port, "error": error})
def _open(self) -> serial.Serial:
ser = serial.Serial()
ser.port, ser.baudrate, ser.timeout = self.port, self.baud, 0.2
# Undgå at DTR/RTS resetter ESP32 når porten åbnes (virker på de fleste boards).
ser.dtr = False
ser.rts = False
ser.open()
return ser
def _run(self):
while True:
try:
ser = self._open()
except (serial.SerialException, OSError) as e:
self._set_state(False, str(e))
time.sleep(2)
continue
with self.lock:
self.ser = ser
self._set_state(True)
# Reset ved hver forbindelse, så ESP32 altid starter i kendt tilstand (driver disabled).
# Firmwaren sender selv boot-log og en status-linje.
self.reset()
try:
while True:
raw = ser.readline()
if raw:
text = raw.decode("utf-8", errors="replace").rstrip("\r\n")
if text:
self.on_message({"type": "line", "text": text})
except (serial.SerialException, OSError) as e:
with self.lock:
self.ser = None
try:
ser.close()
except Exception:
pass
self._set_state(False, str(e))
time.sleep(1)
def reset(self) -> bool:
"""Genstart ESP32 via auto-reset-kredsløbet: RTS trækker EN lav, DTR=False holder IO0 høj."""
with self.lock:
if not self.ser:
return False
try:
self.ser.dtr = False
self.ser.rts = True
time.sleep(0.15)
self.ser.rts = False
return True
except (serial.SerialException, OSError):
return False
def write(self, line: str) -> bool:
with self.lock:
if not self.ser:
return False
try:
self.ser.write((line.strip() + "\n").encode())
return True
except (serial.SerialException, OSError):
return False
clients: set[WebSocket] = set()
loop: asyncio.AbstractEventLoop | None = None
link: SerialLink | None = None
async def broadcast(msg: dict):
data = json.dumps(msg)
for ws in list(clients):
try:
await ws.send_text(data)
except Exception:
clients.discard(ws)
def on_serial_message(msg: dict):
# Kaldes fra læsetråden – flyt over i asyncio-loopet.
if loop:
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
async def heartbeat():
# Kun heartbeat mens en browser er forbundet. Lukkes fanen, stopper firmwaren selv.
while True:
await asyncio.sleep(HEARTBEAT_S)
if clients and link:
link.write("hb")
@asynccontextmanager
async def lifespan(_app):
global loop
loop = asyncio.get_running_loop()
link.start()
hb = asyncio.create_task(heartbeat())
yield
hb.cancel()
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
clients.add(ws)
await ws.send_text(json.dumps(
{"type": "conn", "connected": link.connected, "port": link.port, "error": link.error}))
try:
while True:
cmd = (await ws.receive_text()).strip()
if cmd == "!reset":
ok = await asyncio.to_thread(link.reset)
await ws.send_text(json.dumps(
{"type": "line", "text": "# ESP32 resettes" if ok else "err ikke forbundet til ESP32"}))
elif cmd and not link.write(cmd):
await ws.send_text(json.dumps({"type": "line", "text": "err ikke forbundet til ESP32"}))
except WebSocketDisconnect:
pass
finally:
clients.discard(ws)
if not clients:
link.write("stop") # sidste fane lukket → blød stop med det samme
def main():
global link
p = argparse.ArgumentParser(description="Stepper-test web-interface")
p.add_argument("--port", default="/dev/ttyUSB0", help="seriel port til ESP32")
p.add_argument("--baud", type=int, default=115200)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--http-port", type=int, default=8000)
args = p.parse_args()
link = SerialLink(args.port, args.baud, on_serial_message)
print(f"Åbn http://{args.host}:{args.http_port} (ESP32 på {args.port})")
uvicorn.run(app, host=args.host, port=args.http_port, log_level="warning")
if __name__ == "__main__":
main()