# Architecture PrintGuard is a monolith where the engine is shared code and runs unchanged on CPython (hub mode) and on Pyodide in the browser (local mode). Everything mode-specific is confined to one `Platform` implementation per runtime. The two modes cannot drift apart because there is nothing to drift — they execute the same files. ```mermaid flowchart LR subgraph UI["React UI (presentation only)"] store["zustand store"] end store <-- "JSON commands / events" --> engine subgraph engine["printguard/engine (shared Python)"] registry["camera + printer registries"] monitors["monitors (camera + printer)"] scheduler["fair scheduler"] watchdog["watchdog (defect response)"] vision["vision (preprocess / classify)"] integrations["integration adapters"] notifiers["notifier adapters"] end engine -- "Platform protocol" --> platform subgraph platform["one Platform per runtime"] server["server/platform.py
CPython · LiteRT · PyAV · httpx"] browser["browser/platform.py
Pyodide · LiteRT.js · getUserMedia · fetch"] end server --- mediamtx["MediaMTX
RTSP / RTMP / WHEP / HLS"] integrations --- printersvc["OctoPrint / Moonraker / Elegoo / PrusaLink / Bambu Lab"] notifiers --- push["ntfy / Telegram / Discord / native"] ``` ## The platform contract [`engine/platform.py`](../printguard/engine/platform.py) defines everything the engine needs but cannot implement portably. Identical signatures, different runtimes: | Method | Hub (CPython) | Local (browser) | |---|---|---| | `infer(rgb)` | ai-edge-litert on CPU threads | LiteRT.js in WASM via a JS bridge | | `discover_cameras()` | MediaMTX path list | `enumerateDevices()` | | `open_camera(id, source)` | PyAV reader thread; MediaMTX pulls RTSP and WHEP streams | `getUserMedia` + canvas grabs | | `http(...)` | httpx | `fetch` (CORS applies) | | `encode_jpeg(rgb)` | PyAV mjpeg | canvas `toBlob` | | `load_state` / `save_state` | `data/state.json` | `localStorage` | The UI is presentation-only and speaks one JSON command/event protocol — over a WebSocket in hub mode, over an in-page Pyodide bridge in local mode. The engine cannot tell which transport it is on. **Never add mode-specific logic anywhere else**; if a feature needs a runtime service, extend the Platform contract on both sides. ## The protocol Commands (UI → engine): `discover`, `camera.add/update/remove`, `printer.add/update/remove`, `printer.action`, `printer.test`, `printer.cameras.refresh`, `monitor.add/update/remove`, `notify.test`, `settings.update`, `update.check`, `report.send`. Every command may carry a `req_id`, echoed on the responding event so the UI can resolve pending requests. A **camera** is a video source and a **printer** is a control-service connection (OctoPrint/Klipper/Elegoo/PrusaLink/Bambu); both are registered resources, created and deleted only in their registry. A **monitor** binds one of each (the printer is optional) and carries the inference thresholds and defect-response policy. A printer integration that exposes a webcam registers it automatically as a camera owned by that printer (`Camera.printer_id`): the OctoPrint and Moonraker stream URLs, the Elegoo Centauri chamber camera, and the Bambu chamber camera (RTSP for X1/H2, the proprietary port-6000 protocol for A1/P1). The adapter's optional `cameras()` declares them and the engine reconciles them on printer add/update, and on demand via `printer.cameras.refresh` (the camera registry's Printer cameras tab) to pick up a camera attached later. Such cameras can't be removed on their own and are dropped with their printer. Events (engine → UI): a full `state` snapshot (on connect, after every command, and on a 1 s ticker; it carries the running version and any available update), plus incremental `result`, `alert`, `warning`, `device`, `discovered`, `printer_test`, `notify_test`, `report_sent` and `error` events. `report.send` is the anonymous bug report ([`engine/reports.py`](../printguard/engine/reports.py)): one user-initiated POST of a Sentry feedback envelope — description, optional contact email, user-attached files, a diagnostics bundle and the engine and UI log tails, with every credential redacted — through `platform.http`, so it works identically in both modes. There is no SDK and no automatic telemetry; nothing is sent unless the user submits a report. ## Logging One setup ([`engine/logs.py`](../printguard/engine/logs.py)) serves every runtime: entry points call it once and records flow to stdout for `docker logs`, to a rotating file where no console exists (the desktop app sets `LOG_FILE` in its data directory), and into a bounded in-memory tail. Emitted alert, warning, error and device events are logged as they broadcast, so the tail carries the same timeline the UI shows plus the lifecycle around it — boot, camera attach/drop, resource registration, printer actions, API and socket denials. Uvicorn runs without its own log config so its records land in the same handlers. The UI keeps its own ring ([`web/src/log.ts`](../web/src/log.ts)) of boot milestones, socket drops, toasts, console warnings/errors and uncaught exceptions. Bug reports attach both tails, scrubbed of every configured credential value. `LOG_LEVEL=DEBUG` adds command traces and exception tracebacks. ## The programmatic surface (hub only) The MCP server, REST API and Home Assistant MQTT bridge are thin transports over the same commands the UI sends — they add no logic of their own, so they cannot drift from the dashboard. All are hub only (they need a server runtime, like `server/publish.py`); local mode never mounts them. - [`engine.request()`](../printguard/engine/engine.py) turns the broadcast protocol into request/response by correlating a `req_id`; `engine.snapshot()` encodes a camera's freshest frame as JPEG. Both are mode-agnostic engine methods. - [`server/api.py`](../printguard/server/api.py) is a FastAPI sub-app at `/api/v1`; each route delegates to those methods and is tagged with the scope it requires. - [`server/mcp.py`](../printguard/server/mcp.py) derives its tools from that app with `FastMCP.from_fastapi`, adds a camera-frame tool that returns native image content, and enforces the route scope tags so a caller only sees the tools its token may use. - [`server/mqtt.py`](../printguard/server/mqtt.py) bridges the engine to Home Assistant: it subscribes to engine events as a transport sink and reconciles one MQTT device per monitor through Home Assistant discovery, and routes inbound commands (the Enabled switch, the printer buttons) back through `engine.request()`. The discovery payloads, state blob and command routing are pure functions; an `aiomqtt` session that reconnects on failure and on a settings change wraps them. Control is gated by broker access, not by a token. The REST and MCP surfaces are gated by cumulative scopes (`read` ⊂ `control` ⊂ `manage`); see [docs/api.md](api.md). ## Scheduling inference When a camera is registered its native frame rate is measured once. From then on allocation is fully dynamic: 1. A smoothed estimate of observed inference latency continuously yields the sustainable total rate (`workers / latency`). 2. That capacity is water-filled across in-use cameras (max-min fairness): no camera is allocated beyond its native fps, and surplus flows to cameras that can use it. 3. A free worker takes the most overdue camera and grabs its **freshest** frame at dispatch time. Frames carry a sequence identity, so the same frame is never inferred twice and results always describe the present, not a backlog. MediaMTX bursts the buffered GOP on RTSP connect, so stream fps istrusted from the SDP `average_rate`, else measured only after a warm-up. ## The defect pipeline ```mermaid sequenceDiagram participant S as Scheduler participant P as Platform participant W as Watchdog participant I as Integration adapter participant N as Notifier adapters S->>P: grab freshest frame, infer() P-->>S: classification result S->>W: on_score(monitor, frame, score) alt score ≥ threshold for N consecutive frames W->>I: pause / cancel the linked printer (retried on failure) I-->>W: ok, or "failed" after retries W-->>W: emit alert event (action included) W->>N: snapshot + outcome to every enabled channel else score below threshold W-->>W: streak and alert reset end ``` A failed printer action is retried, then reported in the alert, the UI error feed and the push notification. ## Failing safely A monitor's **watching** state gates inference ([`monitors.monitor_watching`](../printguard/engine/monitors.py)): | Linked printer reports | Watched? | Why | |---|---|---| | no printer linked | yes | nothing to gate on | | `printing` | yes | the job needs eyes | | no state yet / `unknown` | yes | can't tell → watch | | `offline` (unreachable) | yes | losing the signal must not stop monitoring | | `idle` / `paused` / `error` | no (standby) | positively not printing | Only a *positive* "not printing" stands inference down. The watchdog loop then keeps the whole pipeline honest — each sustained condition warns exactly once (after a grace period, so reconnecting sources don't flap) and announces recovery: - a watched camera goes **offline**, - a watched camera stays online but stops producing fresh frames (**stalled** — a frozen RTSP feed must not pass for monitoring), - a linked printer service becomes **unreachable** (defects could no longer pause it). Warnings surface as dashboard toasts and go out through the notification channels. Notifier delivery failures and inference crashes emit `error` events — there is no silent `except: pass` anywhere in the alert path. ## Repository layout ``` printguard/ engine/ shared engine — runs on CPython and Pyodide registry.py camera + printer registries (registered resources) monitors.py monitor config: a camera + printer pairing and its thresholds printers.py registered-printer (integration connection) validation watchdog.py defect response: streaks, printer actions, notifications, health integrations/ printer service adapters (OctoPrint, Klipper, Elegoo, PrusaLink, Bambu Lab, …) notifiers/ alert channel adapters (ntfy, Telegram, Discord, native desktop, …) adapters.py shared adapter contract (id, label, docs_url, JSON-schema config) server/ hub platform: FastAPI, bundled MediaMTX (child process), LiteRT, PyAV api.py REST API (/api/v1) over the engine protocol, scoped by token mcp.py MCP server for agents, derived from the REST API mqtt.py Home Assistant MQTT bridge (device discovery + two-way control) bambu_camera.py Bambu A1/P1 chamber-camera reader (proprietary port-6000 protocol) browser/ local platform: Pyodide bridge to LiteRT.js and getUserMedia pysrc.py builds the engine source archive Pyodide unpacks web/ React + Tailwind UI (presentation only) models/ TFLite encoder, normalisation metadata, class prototypes tests/ engine simulation + adapter contract tests (pytest) ``` ## The static demo Local mode needs no backend at all, so the same `web/dist` build deploys to GitHub Pages: the release workflow zips the engine source (`printguard/pysrc.py`), copies `models/` into the bundle, and every asset is fetched base-relative. The mode picker probes `api/health` — when no hub answers, the hub card becomes a Docker self-host link.