# Architecture > How the pieces fit together. Read [DEVELOPMENT.md](DEVELOPMENT.md) first if you haven't set up the project yet. --- ## 30-second overview Moongate is three layers that talk to each other over HTTP. The diagram in the [README's "How it works"](README.md#how-it-works) section shows the same thing in slightly more user-friendly language; this one labels the parts the way the code does. ``` ┌──────────────────────────┐ │ Cloud middleman │ anonymous identity, │ (identity & lookup) │ per-request token issuance, └─────────────┬────────────┘ "where is my printer right now" │ │ short-lived access token ▼ ┌────────────────────┐ ┌──────────────────────────────┐ │ Moongate App │◄──── LAN ────────►│ Raspberry Pi │ │ (Flutter/Android) │ (preferred) │ ┌─ Klipper + Moonraker │ │ │◄── Cloudflare ───►│ ┌─ Moongate plugin │ │ │ tunnel (away) │ └─ Auth proxy ─── gates │ └────────────────────┘ │ every tunnel-side │ │ request behind a token │ └──────────────────────────────┘ ``` There is no Moongate-operated print server. The cloud middleman is intentionally minimal - it knows enough to tell the app where the printer is right now and to issue a signed access token, and nothing about your prints, your G-code, your camera, or what's on the bed. --- ## The mobile app ### Process tree ``` MoongateApp (lib/app.dart - root widget, lifecycle observer) ├─ ProviderContainer (Riverpod - global app state) │ ├─ themeModeProvider (AppThemeMode: system/dark/light/custom) │ ├─ customThemeProvider (5 user-picked colours; persisted as JSON) │ ├─ fontScaleProvider (0.8 - 1.4) │ ├─ appFontProvider (selected bundled font id; 36-option picker) │ ├─ gridColumnsProvider (1 / 2 / 3) │ ├─ allowRotationProvider (bool; pins SystemChrome orientations) │ ├─ updateProvider (one-shot GitHub release check) │ └─ appVersionProvider (PackageInfo lookup) └─ GoRouter ├─ /splash → SplashScreen ├─ /dashboard → DashboardScreen → many PrinterTile widgets, or one SinglePrinterView (single-printer style, v0.9.69) ├─ /pair → PairingScreen ├─ /printer/:id → PrinterScreen (WebView, kept warm by PrinterWebViewCache; a link off the printer's own origin opens outside it - webview_link_policy.dart, v0.9.70) ├─ /lighting → LightingScreen (per-printer light setup) ├─ /settings → SettingsScreen └─ /theme/custom → CustomThemeScreen ``` ### State and persistence | State | Where it lives | Persisted? | How | |---|---|---|---| | Printer list | `PrinterRegistry` (singleton) | Yes | `SharedPreferences` key `moongate_printers` (JSON) | | Anonymous identity | Cloud middleman + app | Yes | Identity handle stored at the cloud side; the app holds a session for it | | Detected web UI type | `PrinterConfig.uiType` | Yes | Persisted with the rest of the printer config - so the right logo renders on cold launch even when the printer is offline | | Cached LAN URL | `PrinterConfig.lanUrl` | Yes | Learned from the first successful status response; used for LAN-first routing | | Webcam transforms (flip / rotate / fps) | `PrinterConfig.webcam*` | Yes | Server-driven from the plugin; persisted so the first frame after cold launch already renders correctly | | Theme mode | `themeModeProvider` | Yes | `SharedPreferences` key `theme_mode` | | Custom theme | `customThemeProvider` | Yes | `SharedPreferences` key `custom_theme` (JSON of 5 HEX strings) | | Font scale / grid cols / rotation | `settings_provider.dart` | Yes | One `SharedPreferences` key each | | App font | `appFontProvider` | Yes | `SharedPreferences` key `app_font` (bundled font id) | | Current access token + tunnel URL | `PrinterAccessCache` | **No** (in-memory only) | Refreshed from the middleman every few minutes | | Live `PrinterStatus` per tile | `PrinterStatusService.stream` | **No** | StreamController - last emission only | | Dashboard buttons visibility | `dashboardButtonsProvider` | Yes | `SharedPreferences` key `show_dashboard_buttons` (v0.9.41) | | Dashboard style (multi / single) | `dashboardModeProvider` | Yes | `SharedPreferences` key `dashboard_mode`, rides backups; unset means not chosen yet, which gates the first-run "One printer or several?" question (v0.9.69) | | Printer shown on the single-printer dashboard | `singleDashboardPrinterProvider` | Yes | `SharedPreferences` key `single_dashboard_printer`; deliberately not in backups (v0.9.69) | | Control panel layout + macro buttons | `PrinterConfig.controlPanelModules` / `.macroControls` | Yes | Persisted with the printer config (the `moongate_printers` JSON), so they ride backups with no extra keys; malformed or invalid entries are dropped on load (v0.9.64) | ### The service layer The `services/` directory has zero UI. Each file is a focused capability: | File | Responsibility | |---|---| | `supabase_service.dart` | Talks to the cloud middleman. Anonymous sign-in, fetch the current access record for a printer, release a printer on un-pair, list-my-printers refresh | | `printer_access_cache.dart` | In-memory cache of `{tunnel_url, access_token}` per printer. Reuses a token until ~30 s before its expiry, then refreshes via the middleman. Used by every outbound call to the Pi | | `printer_status_service.dart` | The heart of the app. One instance per printer tile. Polls every 4 s. LAN-first when a cached LAN URL is known; falls back to the tunnel within a couple of seconds. Distinguishes Pi-up-but-printer-idle from totally-offline. Sniffs the printer's web UI (Mainsail / Fluidd) on first successful poll and persists it. Also reads a printer's configured light object - when lighting is enabled - so the dashboard bulb shows the light's real on/off state. Reads Moonraker's `webhooks.state` too, so the tile can tell a **shut-down** Klipper (e.g. after an emergency stop) from merely idle and offer a restart instead of the E-STOP triangle. **v0.9.39+:** builds the **multi-toolhead** temperature list. On the current Pi plugin (0.6.12+) every `extruder`/`extruderN`, the active `toolhead` and the `toolchanger` ride the one authenticated `/status` call and the app reads them straight from it (falling back to a direct per-object query for older plugins); the persistent-notification roster order follows the dashboard's **Auto-arrange by status** setting. **v0.9.70:** the status also carries `webcamIsGuess` - the plugin reported no camera entry at all (an empty 0.6.22+ list with only the default snapshot path in the flat fields, nothing external, no gear override), so the synthesised camera is only a guess at the standard path; `WebcamView` tries it quietly and shows the logo, not the unreachable message, when it fails (`webcamIsUnconfiguredGuess`, unit-tested) | | `print_control_service.dart` | Sends `pause` / `resume` / `cancel` / `firmware_restart` / `emergency_stop`, lists and runs Klipper macros (the macro sheet and the lighting on/off/toggle), and starts a stored G-code. Also sets hotend/bed target temperatures for the **preheat** sheet - one `SET_HEATER_TEMPERATURE` request **per heater** (v0.9.45), so on a multi-toolhead machine a heater that rejects a target no longer stops the others and each result is logged. **v0.9.61** adds the dashboard tools' plumbing, all core Moonraker over the same transparent proxy (plain on LAN, tokened through the auth proxy on the tunnel, LAN-only in Direct mode): the **console** (the rolling `gcode_store` history plus sends - sends deliberately never retry across LAN/tunnel, since `gcode/script` only answers when a command *finishes* and a timeout-retry could run it twice) and the **config file system** (list / read / write under the `config` root, plus the Klipper-state read that powers the editor's after-restart watch). The structured editor's line-preserving parse/splice model lives in `models/klipper_config_doc.dart`. Same per-call token retrieval, same LAN-first routing. **v0.9.64** adds the **control panel** plumbing on the same proxy: `runPanelCommand` sends jogs, homing, extrusion, heaters-off and macro buttons as **one POST on a connection resolved once and cached per module** (never the LAN-then-tunnel ladder, which could run a slow command twice), and treats a Klipper refusal (HTTP 400, e.g. "Must home axis first") as a failure since the panel has no console line to show it; `motionPanelState` reads the toolhead (plus the static force-move flag once), and `macroParameters` infers a macro's `params.*` from its own gcode | | `heatsoak_timers.dart` + `models/heat_alerts.dart` | The preheat sheet's **heat-soak alert** (v0.9.67). The sheet arms one record per printer - the temperatures it just set (hotend(s), bed, and a chamber value to wait for on printers that report a chamber sensor) plus a soak time - and the print-notification service's background isolate judges it against the live readings on every poll: a heater counts as at temperature within 3 °C of its *current* target, the chamber against the value typed (the sheet insists on a bed temperature alongside it, since the bed is what heats the chamber), heaters switched off cancel the wait, and a soak time starts a clock only once everything is reached (the moment is written back so a service restart keeps counting). Fires the one-shot **Heat alerts** notification ("At temperature" or "Heat-soak complete") then clears itself. The rules are pure Dart in `models/heat_alerts.dart` (unit-tested); the arms live in local preferences and never ride a backup | | `models/temp_watch.dart` | **Temperature watches** (v0.9.68, plugin 0.6.27+): the printer-side cousin of the heat-soak alert. The Start-print dialog arms an `app-soak` watch (preheat the bed / chamber heater, wait, soak, then the plugin starts the print itself if still idle) and an `app-cool` watch ("ready to remove": after the print has run and ended, bed + chamber back within 5 °C of the readings at Start) through `PrintControlService.armTempWatch` → `POST /server/moongate/temp-watch`; alert texts are worded on the phone with the plugin's `{bed}` / `{chamber}` / `{mins}` placeholders so they arrive localised. `/status` reports the live watches (`temp_watches`), which `PrinterStatus.tempWatches` carries to the tile's one-line "Soaking · 12 min left · then file" (tap = cancel via `DELETE`). Pure Dart, unit-tested | | `printer_webview_cache.dart` | Keeps each printer's Mainsail/Fluidd `WebViewController` warm across visits to the dashboard, so re-opening a printer is instant (no reload). **Pre-warms every printer's page in the background at app startup, so even the *first* open is instant** (v0.9.15). A session's LAN-or-tunnel choice is made when it's built, so since v0.9.57 opening a printer re-checks it against the status poll's live verdict: a tunnel session while the poll runs on LAN is dropped and rebuilt locally (the symmetric case - a dead LAN session falling back to the tunnel - was always handled). Owns the per-session token-cookie refresh (cloud printers only - a Direct printer's page needs no token cookie) and evicts least-recently-used sessions under OS memory pressure | | `webview_link_policy.dart` | **Where a link inside the printer WebView goes** (v0.9.70). Mainsail / Fluidd never navigate the main frame themselves, so a main-frame navigation request is the user tapping a link. The printer's own origin - same host and port as the loaded LAN / Direct / tunnel address, or the same host switching http ↔ https on the standard ports - stays in the WebView; anything else (a GitHub commit in the Update Manager, the AFC panel's Spoolman link on port 7912, go2rtc on 1984, the docs, `mailto:`) opens outside in an in-app browser tab (Android Custom Tab / iOS Safari view via `url_launcher`'s `inAppBrowserView`, falling back to the external app). Over the tunnel (`_usingLan` false) a link into a private / local network (`linkIsOnPrinterNetwork`: RFC 1918, link-local, loopback, IPv6 ULA / link-local, `localhost`, `.local` / `.lan` / bare names) gets a snack bar instead, since the tunnel never carried anything but the printer page. `PrinterScreen`'s navigation delegate applies it and answers `prevent`; a re-attached warm session whose current URL is off-origin is loaded back onto the printer page. Before this the kept-warm WebView followed every link and the printer "became" GitHub with no way back. Pure function, unit-tested (`webview_link_policy_test.dart`). | | `printer_liveness_service.dart` | Tracks each printer's online/offline state from the cloud - subscribing to `last_seen` changes over **Supabase Realtime** plus a periodic RLS-scoped read as a fallback - so the dashboard and the notification service can mark a powered-off printer offline and **skip requesting access for it entirely** rather than polling it every cycle. Realtime delivery is scoped by the same "select own printers" RLS policy, so it widens nothing (v0.9.16). **On returning to the foreground it re-reads every printer's `last_seen` immediately** (a resume re-seed), so a printer powered back on while the app was frozen in the background is recognised as online before the next poll instead of staying stuck offline until a cold start (v0.9.45) | | `printer_registry.dart` | Persistent printer list. `addClaimed` after a successful pair, `remove` plus middleman-release on un-pair, helpers to update individual fields like LAN URL, webcam transforms, and the detected UI type from a successful poll | | `update_service.dart` | One-shot GitHub `latest_version.json` fetch on app launch | | `webcam_fetch_diag.dart` | In-memory, per-printer record of how the webcam fetch loop is doing - which camera URL is in play (query strings stripped, so never a token), the last fetch result, and how many attempts have failed in a row. Read by the in-app bug report, so a camera that never produces a frame - previously invisible in every diagnostic - is now self-explanatory in support | | `print_progress.dart` | Pure helper that computes print progress as a 0-1 fraction matching Mainsail's default *file position (relative)* mode - the printed byte position mapped onto the slicer's gcode body. Shared by the status service and the notification so the tile, the notification, and Mainsail all show the same number (v0.9.17). As of v0.9.53 it also holds the shared **time-remaining / finish-clock estimate** (elapsed ÷ progress, gated until ~2% in), used by both the tile's time chip and the notification card | | `ota_installer.dart` | In-app updater (Android): streams the new release APK to the cache dir with progress, then launches the system package installer through a `FileProvider` + a native `MethodChannel`. Browser fallback on failure (v0.9.17) | | `gcode_layer_parser.dart` | **The G-code layer viewer's parser** (v0.9.72). Pure Dart over the raw bytes of a stored file, run in a background isolate: layers from the slicer's markers (`;LAYER_CHANGE`, `;LAYER:`, Simplify3D's `; layer N,`) or a Z-while-extruding heuristic; every extrusion segment tagged with the part-cooling fan (`M106` without `P`, `M107`), the hotend target (`M104` / `M109`, `SET_HEATER_TEMPERATURE`, and the temperature argument of `PRINT_START` / `START_PRINT`, which wins over an opening `M104 S0`), the speed (`F`) and the volumetric flow (E x filament area / time, diameter from `; filament_diameter =`); a time per layer; `G2` / `G3` arcs approximated; travels, retractions and wipes dropped. Since v0.9.73 also the tool in force per segment (`T` lines, or the MMU macros that replace them: Happy Hare's `MMU_CHANGE_TOOL TOOL=n`, which its Moonraker preprocessor writes in place of every `T` line on upload, ERCF v1's `ERCF_CHANGE_TOOL`, AFC's `CHANGE_TOOL`, v0.9.75; an `M600` advances to the next colour slot, with PrusaSlicer's `;COLOR_CHANGE` and Orca's `; MANUAL_TOOL_CHANGE` comments naming slot and colour) and a tool table for the viewer's key from the settings block's `; filament_colour =` / `; filament_type =` lists (a PrusaSlicer-family file prefers its `; extruder_colour =` where set, v0.9.74). Also `decimateSegments`, the level-of-detail pass the viewer uses for the layers under the current one. The download itself is `PrintControlService.downloadGcode` (streaming, `Range` from Moonraker's `gcode_start_byte`, over the connection the file listing answered on - the same `/server/files/gcodes/` path the thumbnails use, so no plugin change and Direct-mode / third-party printers included) | ### Android native side Most of the app is pure Dart, but a few things need Kotlin: ``` mobile/android/app/src/main/ ├── AndroidManifest.xml # permissions (camera, biometric, notifications, │ # foreground-service, REQUEST_INSTALL_PACKAGES) │ # + a FileProvider for the in-app updater ├── kotlin/com/moongate/app/ │ └── moongate/MainActivity.kt # FlutterFragmentActivity + two MethodChannels ├── res/xml/file_paths.xml # FileProvider paths (the updater's APK cache dir) └── app/proguard-rules.pro # R8 keep-rules for ML Kit + mobile_scanner + CameraX ``` `MainActivity` extends `FlutterFragmentActivity` (not the default `FlutterActivity`) because CameraX requires the activity to be a `LifecycleOwner`. Switching this fixed an earlier camera-binding crash on first launch. It also hosts two small `MethodChannel`s: `com.moongate.app/secure` (toggles `FLAG_SECURE` for the app-lock screen) and `com.moongate.app/install` (v0.9.17 - launches the system package installer on the APK the in-app updater downloaded, shared as a `content://` URI via the `FileProvider`; compiled out of the Play flavor via `BuildConfig.SELF_UPDATE` - #178). --- ## The Pi side Three independent processes running side-by-side on a stock KIAUH / MainsailOS setup: ``` systemd ├─ moonraker.service ──┐ │ └─ moongate plugin │ shipping single Python file: │ (Moonraker component) ─────┤ klipper-plugin/moongate_standalone.py │ │ ├─ moongate-authproxy.service ─────┤ klipper-plugin/moongate_authproxy.py │ (aiohttp HTTP+WS proxy) │ │ │ └─ moongate-tunnel.service ────────┘ cloudflared - Cloudflare Quick Tunnel ``` ### The Moongate plugin (Moonraker component) [`klipper-plugin/moongate_standalone.py`](klipper-plugin/moongate_standalone.py) is symlinked into Moonraker so its auto-discovery picks it up. Single file, no external Python deps beyond what Moonraker already pulls in. Responsibilities: - **Pairing.** Generates the QR + `GATE-XXXX-XXXX` code, registers the printer with the cloud middleman on first successful pair. - **Heartbeat.** Periodically tells the middleman the current Cloudflare tunnel URL (it rotates on each Pi reboot). Since plugin **0.6.15** the loop is also **orphan-aware**: when the middleman answers that this printer was deliberately released by its owner (an explicit "released" reply), or has answered "no such printer" continuously for a full day with no pairing activity, the loop goes **dormant** - one pulse every 6 hours instead of every 5 minutes - posts a notice in the Klipper console, and wakes instantly on `MOONGATE_PAIR` or any accepted heartbeat. This stops a forgotten Pi from calling the middleman forever. - **Tunnel watchdog (0.6.23+).** The one gap in the self-healing story used to be the tunnel itself: heartbeats retry forever and `cloudflared` reconnects after a normal network blip, but a tunnel whose long-lived connection is silently dropped for good (an ISP flap, a router that quietly killed it) would just sit there - the printer reads offline remotely while everything on the Pi looks healthy, and the person it strands is by definition away from home. The watchdog closes that gap. Right after a heartbeat **succeeds** (which proves the internet is up at that moment), the plugin fetches its **own** public tunnel URL with a deliberately-bogus token and expects the auth proxy's `401`. Only a transport failure or Cloudflare's own tunnel-down answer (HTTP 530) counts against it - any delivered response, even a 502, proves the tunnel path works. Three strikes (~15 minutes) and it restarts `moongate-tunnel` through Moonraker's **allowed-services** machine API - the same mechanism the web UIs' service buttons use, so no new `sudo`; the installer adds `moongate-tunnel` to `moonraker.asvc`, and since plugin 0.6.28 the plugin re-adds it itself at every load, because an install updated only through the Software Update panel had never received it (see the post-update chores bullet below) - which mints a fresh URL that the very next heartbeat reports. Guard-railed: at most one probe every 4 minutes, a heal budget of 3 per rolling 6 hours before it stands down with one loud log line (a tunnel that stays dead through fresh URLs needs a human), and it never acts during a WAN outage (heartbeats fail too, so no probe runs and `cloudflared` reconnects on its own once the connection returns). Off with `[moongate] tunnel_watchdog: False`; inert in LAN-only mode. **Zero cloud cost** - probes hit the printer's own tunnel, not the middleman. - **Status.** Aggregates Klipper / Moonraker objects into a single `/server/moongate/status` response - `print_stats`, temperatures, the discovered chamber sensor, webcam transforms, the current tunnel URL, and (0.6.4+) the plugin's own version, which the app compares against the version it shipped with to drive the dashboard's **plugin-update badge**. Since **0.6.23** it also carries a small `tunnel_watchdog` block (heal count, last heal, state), so the app's diagnostics can tell whether a printer's tunnel has been quietly self-healing. Since **0.6.26** it carries `last_notify` - the last accepted `MOONGATE_NOTIFY` message as `{seq, ts, text}`, recorded before the push is attempted - which is how those messages reach Android: iPhones get a push, Android has no push path, so the app's notification service polls `/status` and alerts when the sequence number changes while it is watching (a fresh plugin reports `null`, which the app treats as sequence 0 so the first message after a restart still alerts; the very first sighting after the service starts is a silent baseline, so nothing replays). Since **0.6.30** the app names the chamber sensor it wants folded in (`?chamber=`, the user's pick from the chamber sensor sheet; `auto` returns to the default, and only an object the printer lists is accepted), and that sensor is the one the temperature watches read; the default pick now prefers the object named exactly `chamber`, the same rule as the app. Since **0.6.31** it carries `job_queue` - Moonraker's print queue state, the first queued jobs and the `[job_queue] automatic_transition` flag - which feeds the dashboard's **Up next** line; the queue sheet itself drives Moonraker's own `/server/job_queue/*` routes. - **Control.** A small whitelist of safe actions: `pause`, `resume`, `cancel`, `firmware_restart`, `emergency_stop`, plus (0.6.16) `update_plugin` - which triggers Moonraker's **own update manager** for the Moongate component (the app's one-tap plugin update) and is refused while a print is running. There is no arbitrary G-code endpoint. - **Temperature watches (0.6.27+).** `/server/moongate/temp-watch` (GET / POST / DELETE, owner-token gated like `/status`) and the `MOONGATE_TEMP_NOTIFY` macro (`moongate_temp_notify` remote method) arm one watch per source (`macro`, `app-soak`, `app-cool`): targets by role (extruder / extruderN / bed / chamber), a direction per target fixed from the reading at arm time (`up` = warm to within 3 °C of the LIVE target, a passive chamber against the typed value; `down` = at or below), optional soak minutes (clock starts at temperature, a sag does not reset it), optional `then_start` (Moonraker `print/start` when it fires, only if `print_stats` is idle) and `after_print` (judging waits until a print has run and ended; `cooldown_delta` builds the goals from the readings now). Judged by a 20 s asyncio loop that only runs while a watch exists, persisted to `/temp_watches.json` so a Moonraker restart keeps counting, 6 h stale / 1 h late drops as in the app's rules, fired once through the same path as `MOONGATE_NOTIFY` (push `custom` event + `last_notify` + an `M118` echo). At start the plugin also appends any Moongate macro missing from an installer-managed `moongate.cfg` (`macros_pending_restart` in `/status`) - the installer only ever wrote that file once, so badge-updated printers had been missing `MOONGATE_STATUS` / `MOONGATE_NOTIFY`. - **`MOONGATE_STATUS` console macro (0.6.23+).** A support-first diagnostic run from the Klipper console. It performs one **live signed heartbeat** - the cloud-database half, authoritative, and it also freshens `last_seen` - and the watchdog's own read-only tunnel probe (the tunnel half), then prints a plain-language verdict for each with the fix named in the same breath: re-pair for a released or missing registration, a clock sync for a skew-refused heartbeat (the classic "works on LAN, never remote"), the right journal for a dead auth proxy. All wording lives in two pure functions, so the whole matrix is unit-tested with no network. Installed alongside `MOONGATE_PAIR` / `MOONGATE_RESET_OWNER`; since 0.6.27 the plugin appends it to an installer-managed `moongate.cfg` at load, and Klipper picks it up on its next restart. - **Owner state.** Knows the user this Pi is paired to, persisted at `~/.config/moongate/owner.json`. The `MOONGATE_RESET_OWNER` macro wipes it. - **LAN-only mode (0.6.16+, set by `install.sh --lan-only`).** With `lan_only` in `~/.config/moongate/config.json` the plugin becomes fully cloudless: the heartbeat loop, print-event watcher, and signing-key fetch are **never started** (zero outbound calls), `MOONGATE_PAIR` and the pair page serve a static **`moongate://lan?ip=…&port=…&name=…`** QR instead of a cloud enrollment, and `/server/moongate/status` + `/control` **skip the access-token check** - a request only reaches them after clearing Moonraker's own `trusted_clients` gate, so the plugin trusts the LAN exactly as Moonraker itself does (the same reasoning as the already token-free `/reset-owner`). Re-running the installer without the flag converts the box back. - **Dependency-free LAN-only + embedded hosts (0.6.17+).** The cloud crypto libraries (PyJWT, cryptography) load only when cloud mode uses them, and LAN-only builds **none** of the cloud machinery (no device-key generation, no key file) - so LAN-only needs **zero Python packages beyond Moonraker's own**, which is what lets Moongate run on embedded vendor firmware with no pip (see [3rd-party printer support](docs/third-party-printers.md)). Three optional `[moongate]` options in `moonraker.conf` - `lan_only`, `data_path` (relocates all plugin state off `~/.config` for read-only-home systems), `moonraker_port` - override `config.json`, and the plugin now discovers Moonraker's own port from the server (embedded builds bind `:80`, not `:7125`). A cloud-mode install whose crypto libraries are missing still loads, keeps the LAN endpoints alive, and reports the exact problem in the log, the `MOONGATE_PAIR` console output, and its error responses. - **Post-update chores (0.6.28+).** Moonraker's update manager never executes an extension's `install_script` (the option is deprecated and only scanned for a `PKGLIST` line naming apt packages), so an update from the Software Update panel or the app's badge is a `git pull` plus a Moonraker restart and nothing more. Everything `klipper-plugin/update.sh` was meant to do after a pull had therefore only ever run where someone re-ran the installer or invoked it by hand (found 25/09/2026). Component load is the one hook every update path shares, so the plugin now does the chores that need no root there, idempotently and best effort: its own path on Moonraker's per-clone ignore list (`.git/info/exclude`, which stops the panel reporting an untracked source file), the `moongate-tunnel` line in `moonraker.asvc` plus an in-session allow so the watchdog works from that load rather than the next restart, a fresh copy of the QR pairing page in the web roots, and (since 0.6.27) the macro blocks in an installer-managed `moongate.cfg`. The one chore that needs root, the pre-0.6.14 auth-proxy log migration, stays in `update.sh` for a manual run. ### The auth proxy (v0.4+) [`klipper-plugin/moongate_authproxy.py`](klipper-plugin/moongate_authproxy.py) is a small aiohttp HTTP + WebSocket proxy. It sits in front of *everything* the Cloudflare tunnel can reach. - The cloudflared service points at the auth proxy, not at nginx directly. - Every request must carry a valid short-lived access token (via `Authorization: Bearer`, an `mg_token` cookie, or an `mg_token` query parameter - in that priority order). - Without a valid token: a flat `401 Unauthorized`, constant 13-byte body, no `WWW-Authenticate` challenge, no `Server` header, no fingerprint of what's behind it. - With a valid token: the request is forwarded to nginx (which then routes Mainsail / Moonraker / the webcam stream / the Moongate plugin endpoints exactly as it would on the LAN side). - One exception to "forward to nginx" (v0.9.0+): a request to **`/mg-extcam?u=...`** relays a snapshot/stream from an **external LAN camera** (e.g. a phone webcam Klipper can't see) so it works remotely. The target is SSRF-validated to a literal **private IPv4** only - loopback, link-local/metadata, public addresses, and hostnames are all refused (`_extcam_target_ok`). See [SECURITY.md](SECURITY.md) for the full ruleset. LAN traffic doesn't go through the proxy - nginx still listens on the LAN interface, so phones on the home network reach Moonraker the way they always have. ### The tunnel `cloudflared` Quick Tunnel runs as `moongate-tunnel.service`. It makes an outbound connection to the Cloudflare edge - no inbound ports opened on your router. The URL rotates each time the Pi reboots; the Moongate plugin heartbeats the current one to the cloud middleman so the app always knows where to reach the Pi. Since plugin **0.6.23** the plugin's **tunnel watchdog** (above) restarts this service by itself if the tunnel goes unreachable from the internet while the Pi's own connection is fine, so a silently-dropped tunnel heals within ~15 minutes instead of leaving the printer offline remotely. On a **LAN-only install** (answer **2) Direct (LAN/VPN)** at the installer's connection-mode question, or `install.sh --lan-only`; v0.9.51) neither the tunnel nor the auth proxy exists: the installer skips cloudflared entirely (including its architecture check), never creates the two systemd units, skips the clock check and its htpdate timer, and leaves Moonraker bound to the LAN. Run on a box that already has the tunnel stack, the same choice **retires** it - units disabled, the stale tunnel-URL logs removed so the plugin can't heartbeat a dead URL. The question only appears on interactive runs and defaults to the box's current mode; headless runs and explicit `MOONGATE_LAN_ONLY=` values never prompt. Cloud installs have the mirror-image knob: by default they bind Moonraker to localhost so only the token-gated proxy is network-exposed, and `MOONGATE_KEEP_BIND=1` keeps the existing bind instead - for setups where other machines must go on reaching Moonraker directly at `:7125`, such as a central "farm" Mainsail (see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)). ### Where state lives on the Pi ``` ~/.config/moongate/ ├── owner.json # Which user this Pi is paired to + the printer's identity ├── device.key # Per-device signing key, generated on first install └── v0.4-backup/ # Original moonraker.conf / nginx vhost(s) so uninstall.sh can revert ``` Plus the systemd units: ``` /etc/systemd/system/moongate-tunnel.service /etc/systemd/system/moongate-authproxy.service ``` --- ## The cloud middleman (top level only) The app and the Pi both talk to a small managed backend that handles three things: 1. **Anonymous sign-in.** The app creates an identity on first launch with no email and no password. The handle for that identity is what links the user to their printer(s). 2. **Per-request access token issuance.** When the app wants to talk to the printer, it asks the middleman for a fresh short-lived token; the middleman issues one bound to that user + printer pair. The Pi-side auth proxy verifies it. 3. **Lookup.** The middleman tracks the current tunnel URL for each Pi (heartbeats from the plugin) so the app always knows the right URL to call. What the middleman intentionally does **not** see: - Your G-code, slicer files, or print history (the app never sends print content to the middleman) - Live print state (status calls go phone → Pi directly, not via the middleman) - Anything from your webcam (snapshots go phone → Pi directly) - Anything you'd consider personal (no email, no name, no contact info) **v0.6.3 additions.** The middleman gained two small jobs. **(a) Backup restore** - a one-time, hashed *restore code* (table `restore_grants`; functions `create-restore-grant` / `redeem-restore-grant`) lets a reinstalled app reclaim its printers by re-assigning their ownership to the new identity, so a restored backup reconnects without re-pairing. **(b) Bug reports** - a locked-down `feedback` table (function `submit-feedback`) collects in-app reports with diagnostics, readable only via the dashboard or the secret-gated `read-feedback` function. Ownership is now **cloud-authoritative**: the Pi follows the cloud's owner - it re-binds when a validly-signed token presents a new identity - rather than pinning the first one, which is what makes restore reconnect (see [SECURITY.md](SECURITY.md)). App-side, `diagnostics_service.dart` + `printer_status_registry.dart` assemble the report payload (app / device / network / per-printer status incl. the last LAN-poll outcome). **v0.6.4-v0.6.5.** v0.6.4 enriched those bug reports - they now also carry the **Pi's plugin version** (the plugin self-reports `MOONGATE_PLUGIN_VERSION` in its `/status` reply) and the **remote (tunnel) connection result** next to the LAN outcome, the two biggest triage blind spots - and fixed a v0.6.3 auth-proxy regression that had been failing every tunnel request closed (see [SECURITY.md](SECURITY.md)). v0.6.5 is app-only: a first-run **"How pairing works"** explainer, with no backend or cloud surface. **v0.9.71.** The middleman gained a `rename-printer` function. The iPhone push title is built server-side from the printer's cloud record, and until v0.9.71 that record kept the name given at pairing while the in-app rename only changed the phone's list, so a typo corrected in the app still headed every notification. The phone's list is the source of truth for the name: a rename now writes it to the record too, and once per dashboard launch the app sends up any name that still differs, which is how renames made before v0.9.71 catch up with no re-pair. Direct-mode printers have no record and are skipped; Android notifications are built on the phone and never needed this. **v0.9.49 - releases leave a tombstone.** Removing a printer (in-app, or via `MOONGATE_RESET_OWNER`) no longer hard-deletes its record at the middleman: the row is **soft-deleted** and pruned by the existing cleanup job after one week. Every read path already excluded soft-deleted rows, so nothing user-visible changes - the point is the **heartbeat answer**: a Pi whose printer was deliberately released now gets an explicit "released" reply (instead of an indistinguishable "not found"), which is what lets plugin 0.6.15 stop its cloud chatter immediately rather than probing a dead record for a day. See [SECURITY.md](SECURITY.md) for why this distinction leaks nothing. The deep design - schemas, token mechanics, key rotation, the specific cross-tenant isolation guarantees - lives in [`docs/v0.3-supabase-design.md`](docs/v0.3-supabase-design.md) and [`docs/v0.4-secure-remote-access-design.md`](docs/v0.4-secure-remote-access-design.md) alongside the alternative-architectures discussion that explains why we landed here. --- ## CI / build pipeline `.github/workflows/build-android.yml`: ``` push to master ├─ checkout ├─ setup-java@v4 (Temurin 17) ├─ setup-flutter@v2 (stable channel) ├─ flutter pub get ├─ Decode the keystore from GitHub Secrets → key.properties ├─ flutter build apk --release --flavor github --dart-define=MOONGATE_CHANNEL=github ├─ flutter build appbundle --release --flavor play --dart-define=MOONGATE_CHANNEL=play ├─ Publish the signed github APK as a GitHub Release asset (Moongate-v.apk) ├─ Upload the play .aab as the "Moongate-play-aab" workflow artifact (manual Play upload) ├─ Regenerate APK/latest_version.json (apk_url → the Release asset) └─ git commit + push the manifest → "Release Moongate-vX.Y.Z [skip ci]" ``` The `[skip ci]` suffix prevents the commit-back from re-triggering CI. The in-app update banner ([`UpdateService`](mobile/lib/services/update_service.dart)) polls `latest_version.json` on launch and shows the banner if the remote `build_number` exceeds the installed one. CI only fires on `master`. Feature branches (like `v0.4-secure-remote`) don't build APKs automatically - that's intentional, so unreleased branches stay out of the in-app updater. **Build flavors (#178).** The Android build has two flavors on a `distribution` dimension: `github` (the sideload APK for GitHub Releases + KIAUH, keeps the self-updater) and `play` (the App Bundle for Google Play, no self-updater and no `REQUEST_INSTALL_PACKAGES`, since Play forbids self-install). Both keep the same `applicationId` and signing key, so users move between channels in place with no wipe. A Dart flag ([`config/build_channel.dart`](mobile/lib/config/build_channel.dart), fed by the `MOONGATE_CHANNEL` dart-define) plus `BuildConfig.SELF_UPDATE` gate the updater code; the permission + `FileProvider` live in `src/github/AndroidManifest.xml`. Full rationale in [`docs/design/play-flavor-plan.md`](docs/design/play-flavor-plan.md). --- ## Data flow walkthroughs ### Pairing (QR path) 1. User runs `MOONGATE_PAIR` in Klipper console → registered as a Moonraker remote method → plugin generates a `GATE-XXXX-XXXX` code and stashes a pending pair record. 2. Plugin pushes a clickable LAN pair URL to the console via `M118`. The QR page is **LAN-only by design** - the Cloudflare tunnel side returns 401 for everyone, including the pair page, because the user pairing doesn't have a token yet. 3. User opens `http:///moongate-pair.html` on a PC / tablet / second phone on the same WiFi. The page fetches the current pending pair info from the Moongate plugin and renders the QR. 4. User opens the Moongate app on their phone → tap **+** → **Scan QR** → camera reads the payload. 5. App registers itself with the cloud middleman (anonymous sign-in if it's the first launch), then submits a claim for this printer using the code from the QR. 6. Plugin verifies the claim (next heartbeat to the middleman picks up the new owner) and writes `owner.json`. The app sees the printer in its registry; the dashboard tile spins up its status service. ### Pairing (manual code path) If the phone's camera doesn't work (permission denied, hardware fault, can't focus on the QR), the `GATE-XXXX-XXXX` code shown in the Klipper console can be typed into the app's pair screen instead of scanning - two 4-digit boxes with a numpad. The rest of the flow is the same: code in (no `pi_public_key` sent - the server uses the one already on the enrollment row), claim out, owner.json written, tile spins up. ### Status poll (every 4 s per tile) 1. `PrinterStatusService._doPoll()` asks `PrinterAccessCache` for a `{tunnel_url, access_token}` pair. If the cached token is still fresh, returned instantly; otherwise the cache fetches a new one from the middleman. 2. If the printer has a known LAN URL (learned from a previous poll), try it first with the access token in the `Authorization: Bearer` header. 2 s fast-fail timeout. The call also names the chamber sensor the user picked (`chamber=`, v0.9.77) so the plugin folds that one in. 3. If LAN fails (off-WiFi, slow LAN, etc.), try the tunnel URL with an 8 s timeout. The auth proxy verifies the token and forwards to Moonraker. 4. If both fail, drop the token cache (in case the middleman revoked it) and retry once with a fresh token. 5. If even that fails, do a quick HEAD probe to LAN + tunnel. ANY reply (auth proxy 401, nginx 200, upstream 502) proves the Pi is reachable on the network → emit a `PrinterStatus.waiting` ("Connected - Printer idle"). Only when nothing on either path answers does the tile go fully offline. 6. The tile widget rebuilds with the new temps, progress, webcam tick. ### Print control 1. Tile button → `PrintControlService.sendAction('pause')`. 2. Same access-token lookup and LAN-first routing as the status service. 3. Try `POST /server/moongate/control?mg_token=...&action=pause` first; the auth proxy + plugin both verify the token. 4. Return `true` as soon as the Pi answers 200. ### Direct (LAN/VPN) status poll + control (v0.9.51) A printer added through the pairing screen's **Direct (LAN/VPN)** tab (or flipped to Direct in its edit dialog) short-circuits all of the above: 1. `PrinterStatusService._doPoll()` sees the registry-live `lanOnly` flag and calls `_doPollLanOnly()` instead: one GET to `/server/moongate/status` at the stored LAN address with an **empty token** - no `PrinterAccessCache`, no middleman, no tunnel fallback. The response parses through the exact same code path as the cloud route. 2. `PrintControlService` does the same for actions, and the printer page loads Mainsail/Fluidd straight from the LAN root with no token cookie. 3. The background notification service **skips Direct printers entirely** (their cards would need the cloud path), so they cost zero middleman calls there too. 4. Unreachable simply means offline - there is nothing to fall back to, which is the honest answer for a LAN/VPN-only machine that you're away from. Direct-added printers mint a **local id** (`lan-
`), so `PrinterConfig.cloudPaired` can tell them apart from cloud printers that merely have Direct mode switched on: only the former have no cloud row to release on removal or return to when toggled. Cloud-pairing a machine that already exists as a Direct tile **absorbs** the old tile (same LAN host + port) - the new cloud tile inherits its persisted settings and the Direct tile is retired. ### Tunnel URL rotation `cloudflared` Quick Tunnels get a fresh URL every time `cloudflared` restarts (so every Pi reboot, plus any manual restart). To make this transparent: - The Moongate plugin tails the cloudflared log for the current URL and heartbeats it to the cloud middleman. - When the app polls, `PrinterAccessCache` fetches the *current* tunnel URL from the middleman alongside the access token. - Result: no re-pairing, no QR re-scan, no user action of any kind needed when the Pi reboots and the URL changes. --- ## Key design decisions ### Klipper-aware config editing and console completion The config editor keeps its line-preserving parser: existing values are spliced in place and new fields/sections are inserted without reserializing the file. A catalog generated from a pinned Klipper `Config_Reference.md` provides documented section, field, type, enum, and helper text. Includes resolve against Moonraker's config listing and open each source file in its own editor. Saves are deliberately single-file transactions: they re-read the remote file first, create a backup, and retain the existing restart/restore safety path; local checks are guidance, not a replacement for Klipper loading the config. Console suggestions use the connected printer's `/printer/gcode/help`, macros, and local command history. The pinned official catalog enriches live commands with parameter metadata and serves as the offline fallback. Completion replaces only the token at the cursor and supports touch, Tab, and Up/Down history. `M112` bypasses the queued G-code endpoint and uses Moonraker's immediate emergency-stop endpoint. ### Control panel: per-printer modules, single-shot sends (v0.9.64) The tile's macro button opens a control panel built from reorderable modules (temperature, motion, macros) whose layout and macro buttons are stored on the printer's own config, so they travel in backups without any new settings keys. Three rules keep it safe on a real machine: - **Every send happens exactly once.** `gcode/script` only answers when the command finishes, so a heat-and-wait macro outlives any sane HTTP timeout; a transport-level retry would run it again. Panel sends go over one resolved connection, and a refusal from Klipper is reported as a failure rather than hidden behind a console convention. - **Motion scripts cannot latch the printer.** Jogs and extrusions are wrapped in `SAVE_GCODE_STATE` / `RESTORE_GCODE_STATE`; because Klipper abandons a script at the first error, a failed move (unhomed axis, past a limit) fires a best-effort restore so relative mode never stays applied. - **Macro buttons are validated before anything is built.** Macro and parameter names must be identifiers, values must be single-line, an OFF toggle is omitted rather than sent as `NAME=0` (Klipper hands params to Jinja as strings, and `'0'` is truthy), and the exact command is previewed before it runs. Layout follows the app-wide sheet rule: keyboard insets plus a bottom `SafeArea`, so the panel's lower controls clear the system navigation bar (v0.9.65). ### LAN-first on every poll, no skip backoff Every poll tries LAN before the tunnel when a cached LAN URL is known. There used to be a "3 LAN failures → skip LAN for 5 minutes" backoff in v0.3, but it had a frustrating bug: once you went on cellular at any point, returning to home WiFi did not flip back to "Local" until the 5-min timer expired. The user-perceptible "stuck on Tunnel at home" was worse than the off-LAN polling overhead. v0.4.0 removed the skip. The off-LAN cost is an extra ~2 s per poll cycle (LAN timeout fires before the tunnel attempt). Polls stretch from 4 s to ~6-10 s when away from home. That's the trade we explicitly accepted in exchange for predictable LAN-first behaviour. ### Connectivity-aware camera feed rate The dashboard keeps two webcam refresh rates - a faster "Local" rate and a throttled "Tunnel" rate (v0.9.14). As of v0.9.15 the tile picks between them by the **phone's network type** rather than by which URL the status poll happened to win on: an `onMobileDataProvider` (backed by `connectivity_plus`) drives the choice, so the faster Local rate applies on **any Wi-Fi** - including when you're away and reaching the printer over the tunnel - and the throttled Tunnel rate applies only on **mobile data**, where cellular data actually costs something. Reaching a printer on the LAN is always Wi-Fi, so home behaviour is unchanged. As of v0.9.48 the Local rate **defaults to Raw** (the camera's own target FPS) - Wi-Fi can afford it - while the Tunnel rate keeps its 3 s default; an explicitly chosen rate is always kept. The full-screen camera view ignores the dashboard throttle entirely and always runs at the camera's own frame rate. ### Print progress matches Mainsail (file-relative, single source) Progress used to be computed two different ways - the dashboard tile preferred elapsed-time ÷ the slicer's time estimate, while the notification used the raw file fraction - so the tile, the notification, and Mainsail could all disagree (the tile typically ran a few percent ahead, because slicer time estimates are routinely off). As of v0.9.17 a single helper, [`print_progress.dart`](mobile/lib/services/print_progress.dart), is the only place progress is derived, for **both** the tile and the notification. It matches Mainsail's default **"file position (relative)"** calculation - `(file_position − gcode_start_byte) / (gcode_end_byte − gcode_start_byte)`, the printed byte position mapped onto the slicer's gcode body (the start/end offsets come from the file metadata the status service already fetches) - and falls back to `display_status.progress`, then the raw `virtual_sdcard.progress`, until those offsets are known. The **time-remaining / finish-time estimate** lives in the same file, and since v0.9.56 it mirrors **Mainsail's ETA method**: the average of up to three independent projections - file progress (elapsed × (1 − p) ÷ p), filament (elapsed scaled by `filament_used` ÷ the file's `filament_total`), and the slicer's own `estimated_time` minus elapsed (dropped once a print overruns it). The two metadata inputs ride the same `/server/files/metadata` fetch the progress calculation already makes, so the upgrade cost no extra network calls. With a slicer estimate present the chip shows from the first seconds of a print; without one it behaves as before (hidden below ~2% progress), and it always hides while paused. Shared by the dashboard tile's time chip and the notification card so the two can never disagree. ### Liveness-gated polling for offline printers Status polling a powered-off printer is wasted work: every cycle mints an access token from the middleman (an Edge Function call) only for the request to fail. As of v0.9.16 a `PrinterLivenessService` learns each printer's online/offline state from the cloud's `last_seen` (the Pi heartbeats it) over a **Supabase Realtime** subscription, with a periodic RLS-scoped `SELECT` as a fallback when the socket is down. While a printer reads as offline, both the dashboard and the background notification service **stop requesting access for it**, so an offline Pi costs zero Edge Function invocations and far less mobile data / battery. Reading `last_seen` over Realtime (or a plain RLS-scoped read) is **not** a billed Edge Function call, unlike `/printer-access`. Online printers behave exactly as before, and LAN polling still works even if the cloud is unreachable - liveness only *suppresses* remote token requests, it never gates the LAN path. Two v0.9.48 additions close the remaining cost gaps. A printer whose cloud row is **gone** (deleted, or re-bound to another device by a restore) is invisible to liveness - it has no `last_seen` at all, which reads as "unknown" and deliberately fails open - so before v0.9.48 such a tile minted-and-404'd on every 4 s poll forever. The access cache now remembers a "not found" answer and re-probes on an exponential back-off (1 min doubling to a 15 min ceiling) instead. And the plugin (0.6.13) applies the same idea to its JWKS key fetches: a fetch that keeps failing retries at most every 2 minutes rather than on every incoming request. v0.9.71 puts the same back-off on **failure-driven re-mints**. When the Pi answered a poll with 401 the app dropped its cached token and minted a fresh one, so a Pi that rejects every token (a clock far enough out to fail the replay window) on a 3-second poll made one phone 1,110 `/printer-access` calls an hour. The first rejection still gets an immediate re-mint, since a token really can be invalidated server-side; repeats are held 1 min doubling to 15, during which the cached token stays and the poll goes straight to its offline/waiting verdict. A user-driven Retry still gets one real probe. v0.9.49 closes the same gap on the **Pi side**: an orphaned plugin (its cloud record gone for good) used to heartbeat every 5 minutes forever. Plugin 0.6.15's heartbeat loop now goes **dormant** - a 6-hourly pulse - after a day of confirmed "no such printer" answers, or immediately on the explicit "released" reply that removing a printer now produces (see the tombstone note above). Any pairing activity wakes it instantly - and (0.6.19) an **open pairing session holds it awake**: a "released" reply is expected mid-re-pair (the replacement record only appears once the code is redeemed), so while an un-expired code is outstanding the loop keeps its fast cadence instead of dorming, and the freshly paired printer reports in within seconds. ### Local-only mode (v0.9.48) An opt-in top-bar toggle turns the tunnel off as a *transport*: while active, the status poller skips the tunnel fallback and its reachability probes, the printer WebView only loads over LAN (a kept-warm tunnel session is dropped rather than reused), the cameras inherit LAN-only from the poller, and the background notification isolate skips the tunnel base too - zero remote traffic. Printers with no reachable LAN address settle to offline. Pairing and cloud identity are untouched; the pref is re-read every poll (and each notification tick), so flipping it takes effect within ~4 s without restarting anything. The mode persists across restarts but is deliberately excluded from settings backups, so a restore can never silently cut remote access. ### Direct (LAN/VPN) mode is per-printer, and distinct from Local-only (v0.9.51) **Local-only** (above) is a *transport* toggle for cloud printers: identity and pairing stay cloud-side, the tunnel is just not used while it's on. **Direct (LAN/VPN)** is a per-printer *identity* choice: the printer has no cloud existence at all (or ignores it while switched), and the trust model is Moonraker's own `trusted_clients` - the same thing that lets Mainsail on your desktop control the printer without a password. Moongate deliberately adds no second gate there: on the LAN you are exactly as trusted as any other Moonraker client, and remote access is whatever your own VPN provides. The flag lives on `PrinterConfig.lanOnly` and is read **registry-live** by the status/control services and the printer page, so the edit-dialog switch takes effect on the next poll with no restart and no service rebuild. The switch only shows for cloud-paired printers (both directions are meaningful there); a Direct-added `lan-` printer has no cloud row to switch back to - converting one to cloud is a real pairing, which then absorbs the old tile. When the dashboard tile can't render a webcam (camera off, printer powered off, etc.) it shows the printer's web-UI logo as a placeholder - Mainsail or Fluidd. Detection is a one-time HTML sniff of the root page. In v0.4 the result is persisted on `PrinterConfig`, so a fresh cold launch shows the correct logo immediately, even when the printer is currently offline. Before v0.4, detection had to re-run on every cold launch and the logo only appeared after the first successful poll. ### Pi-up-but-printer-idle as its own state When the Pi is reachable but the moongate `/status` path keeps failing (e.g. on a Creality K3 where the printer-power toggle inside Mainsail is off, so Klipper isn't running), the tile emits `PrinterStatus.waiting` rather than `PrinterStatus.offline`. The tile shows the logo + "Connected - Printer idle" with no spinner, instead of looking dead. Distinguished by a HEAD reachability probe - any HTTP reply at all (auth proxy 401, nginx 200, upstream 502) proves the Pi is up. ### Per-tile, not per-app, status polling Each `PrinterTile` owns its own `PrinterStatusService`. They poll independently. This means: - Adding / removing a printer doesn't stall the others - A timing-out printer doesn't slow the dashboard refresh of nearby ones - One offline printer doesn't poison the connection state of the others It costs N parallel HTTP loops where N is the number of printers, but that's fine - typical N is 1-3, and the polls are 4 seconds apart. ### Plugin is a single file, no external Python deps The Moongate plugin runs inside Moonraker's process and has access to anything Moonraker depends on. Staying within that surface (stdlib + Moonraker's existing deps) reduces installation to "copy one file and restart Moonraker". No virtualenvs, no `pip install`, no breakage when Moonraker updates. The auth proxy is the one exception - it pulls in `aiohttp` (`pip install` inside Moonraker's venv) because there's no Moonraker-native way to intercept everything *before* Moonraker. The verifier classes (token-signature checks, owner state) are imported from the plugin file rather than duplicated, so signature semantics stay single-source. One consequence of that sharing: the plugin file loads its signing/verification libraries lazily (v0.6.20, so LAN-only installs never pay their memory cost), and the proxy - a separate process - therefore runs that load itself at startup and refuses to start if the libraries are missing (v0.6.21). A proxy that can't verify tokens must fail loudly at boot, not crash on every request. ### Cloud middleman, not a Moongate-operated print server The middleman is intentionally minimal - anonymous identity, per-request token issuance, lookup. It never sees your G-code, your prints, your webcam, your bed. All print-side data flows phone ↔ Pi directly. This is both a privacy decision and a scope decision: a print server is a much larger thing to operate than a lookup table. ### The G-code layer viewer draws 3D with no engine (v0.9.72) `features/printer/gcode_viewer_screen.dart` shows a stored file as a layer stack you orbit with a finger, coloured by five heat maps (temperature, flow rate, layer time, speed, fan speed) with the same 11-stop ramp Orca's preview uses, spread from the file's minimum to its maximum. There is no 3D engine and no new package. Each layer is a flat set of segments at one Z, so the painter sets **one orthographic 4x4 canvas transform per layer** (the orbit matrix times the lift to that Z) and draws the layer's mesh; layers go bottom to top, which is exact occlusion as long as the camera stays above the top layer, so the tilt is clamped to 5..88 degrees. Each layer is **one triangle mesh** (`dart:ui` `Vertices`): every segment a bead at its real extrusion width (0.42 mm, widened when zoomed far out so it still covers a pixel), six vertices and four indexed triangles, dark edges and a full-colour middle so walls show their layer lines. The stack under the current layer is drawn from a decimated copy (chords within about half a pixel of the original, a 600k-segment budget that loosens the tolerance for huge prints); the current layer is full detail. Two things it deliberately is NOT: `drawRawPoints(lines)`, which the Impeller renderer splits into one draw op per line pair (a 300k-segment file froze and then killed the app on the first device build), and a WebView around a JavaScript viewer (per-segment colouring is not built into those, 12 MB across the JS bridge is slow, and it is a second language for one screen). Parsing runs in `Isolate.run` through a static helper, because a closure written inside the State's own async method would capture the State and be refused as an isolate message. Start print on the viewer pops back into the Start-a-print sheet's normal confirm flow. **Colour view (v0.9.73).** A sixth chip paints every line in the filament it was printed with. It is the same mesh builder with a different lookup table: the ramp modes quantise a value into 48 bands, Colour uses one band per tool (`_bands` = the tool count, range 0..n-1), so a segment's key slot maps to its exact colour and the decimator merges by tool like it merges by band. Colours come from the parser's tool table (the slicer's `filament_colour` list, or an `M600` change's colour; for a PrusaSlicer-family file the Plater's `extruder_colour` first where set, which is what its preview paints with, v0.9.74), with a six-colour fallback palette for files that carry none or give every tool the same one; the legend turns into a key, one swatch per tool with the filament type. "Save to phone" (`features/printer/gcode_save.dart`, v0.9.74, reached from the Start-a-print rows and the viewer's ⋮ menu) is a fresh whole-file `downloadGcode` behind a progress dialog, handed to `FilePicker.saveFile` with the bytes (SAF on Android, the Files export picker on iPhone), the same path as the printer backup. **v0.9.76:** both go through `services/gcode_cache.dart`, a session cache: the whole file on disk in the app's cache folder keyed by printer, path, modified time and size (about 300 MB, least recently used out first, emptied at every app launch from `main`; the last parsed model also stays in memory), so the viewer no longer sends a `Range` header and one download serves viewing and saving for the rest of the run. The Colour view's key gains a **Sliced / Loaded** switch when `PrinterStatus.filaments` (plugin 0.6.29's `filaments` list: Happy Hare's `ttg_map` + `gate_color_rgb` + `gate_material` + `gate_name` + `gate_status`, or AFC's lane `map` + `color` + `material` + `filament_name` + prep / load sensors, folded by the plugin's `build_filaments()`) names one of the file's tools: Loaded swaps the lookup table to the gate colours, hatches an empty gate and dots a gate whose colour differs from the slice. A long-press on a Start-a-print thumbnail opens `_GcodeThumbPreview`, the largest embedded thumbnail (falling back to the row's own) with the file name and nothing else. The ⋮ menu's "Opens with" (`gcodeViewer.openWith`: `auto` / `last` / a mode name) decides the first view: Auto is Colour for a multi-material file (more than one tool or colour slot, decided from the file so a single-extruder M600 print counts) and Fan speed otherwise; the last chip tapped stays under `gcodeViewer.mode` for "Last used". --- ## Where to next - [SECURITY.md](SECURITY.md) - threat model, what the tunnel exposes and what it doesn't, how to audit - [DEVELOPMENT.md](DEVELOPMENT.md) - practical setup, running, building, debugging - [docs/setup-guide.md](docs/setup-guide.md) - end-user perspective - [`docs/v0.3-supabase-design.md`](docs/v0.3-supabase-design.md) and [`docs/v0.4-secure-remote-access-design.md`](docs/v0.4-secure-remote-access-design.md) - the deep design rationale, alternatives discussion, and the specific guarantees the cloud middleman + auth proxy make