# Architecture This document covers the design decisions, the wire protocol, and how the internal packages fit together. If you just want to use `printcontrol`, `README.md` is enough. ## Goals and non-goals **Goals** - Single static binary, zero runtime dependencies (no Python, no venv). - Read-mostly UX: the printer is in charge during an SD print, we observe. - Theme-aware, omarchy-style TUI — let the terminal palette do the work. - Backend-agnostic at the seams: USB serial today, Octoprint / Moonraker later. - Crash-tolerant: dropping serial mid-print should not kill the program; the printer keeps printing from its SD card regardless. **Non-goals (for now)** - Slicer integration. - Multi-printer farms. - Web UI / remote access. Use Octoprint or Mainsail for that. - Persistent print history / analytics. ## Process model There are three logical actors: ``` ┌────────────┐ Events ┌─────────────┐ bytes ┌────────────┐ │ Bubble Tea │ ◄────────── │ Printer │ ◄──────► │ /dev/tty* │ │ model │ │ goroutine │ │ │ │ (UI) │ Send(cmd) │ │ │ (Marlin/ │ └────────────┘ ─────────────► │ │ Klipper) │ └─────────────┘ └────────────┘ ``` 1. **UI goroutine** — Bubble Tea's event loop. Owns the model and the screen. Reads from `printer.Events()` via a `tea.Cmd` that blocks on the channel. 2. **Printer goroutine** (`internal/printer`) — owns the serial handle. Reads incoming lines from a dedicated reader goroutine and writes outbound G-code itself. This makes the serial handle single-owner and removes the need for write-side locking on the port. 3. **Reader goroutine** — spawned by the printer goroutine for the lifetime of the connection. Sits in `bufio.ReadString('\n')` and forwards complete lines via a buffered channel. The UI never touches the serial port; the printer never touches the screen. All cross-goroutine communication is via channels. ## Package layout ``` internal/printer/ state.go Printer state model. Pure data + helpers (Progress, ETA). parse.go Line parser. Stateless except for mutating *State. printer.go Lifecycle (Connect/Disconnect), serial I/O, polling. Exposes high-level commands (SetHotend, Pause, ...). parse_test.go internal/tui/ style.go Lipgloss styles, adaptive colours. model.go Bubble Tea Model: Init / Update / View, key handling. cmd/printcontrol/ main.go Flags + tea.NewProgram. ``` The boundary that matters is the one between `internal/printer` and the rest. A future Octoprint backend implements the same shape — `Events() <-chan Event` plus the high-level command methods — and the TUI doesn't care. ## State model ```go type State struct { Connected, Online bool Firmware string Port string Baud int PrintState PrintState // Idle | SDPrinting | HostPrinting | Paused | Error ErrorMsg string Temps map[string]TempPair // "T", "T0", "B", "C", ... SDByte, SDTotal int64 SDFilename string SDStart time.Time // for ETA FeedratePct, FlowPct, FanPct int BabystepZ float64 SDFiles []SDFile // most recent M20 listing SDListing bool // true while M20 output is being collected } ``` State is mutated only inside the printer goroutine, then snapshotted into each `Event`. The UI receives a value copy and reads it freely — no shared mutable state across goroutines. The two booleans `Connected` and `Online` are distinct on purpose: - `Connected` — the serial port is open. - `Online` — the firmware has spoken to us (we've seen `ok`, `start`, or `Marlin`). Some operations (priming auto-report, sending commands) wait for this. ## Wire protocol Marlin / RepRap / Klipper all speak the same surface-level G-code dialect over serial. We only use a small subset. ### Reads | What we look for | Regex | |---------------------------------|--------------------------------------------------| | Temperatures | `(T\d*|B|C):\s*\s*/\s*` | | SD print progress | `SD printing byte /` | | Not currently SD printing | `Not SD printing` | | SD print finished | `Done printing file` | | File opened (printer reports it)| `File opened: ` | | Firmware identification | `FIRMWARE_NAME: SOURCE_CODE_URL:...` | | SD listing start | `Begin file list` | | SD listing entry | ` ` (optional `"long name"`) | | SD listing end | `End file list` | Online detection: any line starting with `ok`, or containing `start` / `Marlin`, flips `state.Online = true`. Lines starting with `Error` or `!!` flip the print state to `Error` and surface the message in the footer. ### Writes | Action | G-code | |-------------------------|-----------------------------------------| | Identify firmware | `M115` | | Request temps once | `M105` | | Request SD status once | `M27` | | Disable auto temp report| `M155 S0` *(prime block)* | | Disable auto SD report | `M27 S0` *(prime block)* | | Mount SD card | `M21` | | List SD files | `M20` | | Select SD file | `M23 ` | | Start / resume SD print | `M24` | | Set hotend N | `M104 T S` | | Set bed | `M140 S` | | Set fan (0–100%) | `M106 S<0..255>` / `M107` | | Feedrate % | `M220 S` | | Flow % | `M221 S` | | Babystep Z | `M290 Z` *(Marlin only)* | | Pause SD print | `M25` | | Cancel SD print | `M524` *(Marlin 2.0+)* | ### Polling We send `M105` every 2 s and `M27` every 5 s. **Auto-reporting is deliberately turned off** in the prime block (`M155 S0` / `M27 S0`) — when both auto-reports and our own polls are active, Marlin can interleave them character-by-character, producing mangled output like `TT::57.4957.49`. Owning the polling cadence is simpler than racing the firmware for the serial line. ### Baud + port auto-detection `Connect("", 0)` triggers a full scan: every `/dev/ttyACM*` and `/dev/ttyUSB*` is probed against the candidate baud list `[250000, 115200, 230400, 500000, 57600]`. For each (port, baud) pair we: 1. Open the device. 2. Sleep ~400 ms so DTR-resetting boards finish booting. 3. Write `M115`, wait ~1.2 s, write `M115` + `M105` again. 4. Read for up to 1.5 s, looking for an *unambiguous* match: `Marlin` / `FIRMWARE_NAME` / `RepRapFirmware` / `Klipper` / a well-formed `T:N/N` or `B:N/N` line. Two-letter tokens like `ok` and short words like `start` were tried first and abandoned: they appear too often in misaligned-baud garbage and led to false-positive locks. The strict matcher trades ~3 s per failed probe for a guaranteed-correct result. First match wins. Subsequent ports / bauds are skipped. Pass `-port` and/or `-baud` to bypass detection entirely. ### What we are not (yet) doing **Host-side print streaming.** Marlin's host-mode streaming uses checksummed line-numbered commands: ``` N * ``` …and supports resend requests when the printer drops a line. We don't implement this because the MVP is monitor-an-SD-print. When we add it, it goes in `internal/printer` behind a `StartHostPrint(io.Reader)` method, keeping the UI ignorant of the protocol detail. ## Lifecycle Connect: 1. Resolve the candidate port list (caller-specified, or every `ttyACM*` / `ttyUSB*` device). 2. If `baud == 0`, run the auto-detect probe (see *Baud + port auto-detection* below). Otherwise just open the first responsive port at the explicit baud. 3. Apply a 500 ms read timeout on the chosen handle. 4. Start the printer goroutine, which spawns its reader goroutine. 5. Two seconds later, send the priming block: `M115`, `M155 S0`, `M27 S0`, `M105`, `M27`. The `S0` lines actively disable any auto-reporting Marlin may have enabled from a previous session — we own polling ourselves so the two streams can't collide. Disconnect: 1. Cancel the printer goroutine's context. 2. Wait for it to drain (`<-p.stopped`). 3. Close the port. Reader goroutine exits on the next read error. Crash safety: if the program panics or the user kills it, the printer keeps printing from its SD card. No cleanup is required on the printer side. The next `printcontrol` invocation reconnects and re-discovers state via the periodic `M27` / `M105`. ## TUI design `internal/tui` is a small Bubble Tea model. Two `tea.Cmd` sources drive it: - `tickCmd()` — 1 Hz `time.Tick`, used to refresh the ETA display. - `waitEvent(printer)` — blocks on `printer.Events()`. After each event the command returns and is re-scheduled. Styling is in `internal/tui/style.go` using `lipgloss.AdaptiveColor` against the ANSI 16-colour palette. This is deliberate: by referring to colour `5` ("magenta") rather than `#bb9af7`, we let the user's terminal theme define what magenta actually looks like. Tokyo Night, Catppuccin, Gruvbox, Nord — they all just work without code changes. The layout is four rows tall: 1. Two side-by-side panels: Status (left), Temperatures (right). 2. A full-width Controls panel. 3. A full-width Log panel — its height is computed from `m.height` minus the rows the other panels need, so the footer is always reserved. 4. The footer key hints. Below ~60 columns, the top row collapses to a single column. We don't attempt fancy responsive layouts; this is a control surface, not a website. ### SD card picker Pressing `s` sends `M21` + `M20` and sets `sdPickerWait = true`. The parser collects the `Begin file list … End file list` block into `State.SDFiles`; when the listing completes (and `SDListing` flips back to `false`), the event handler copies it into the model and switches focus to `focusSDPicker`. While the picker has focus it overlays the Log panel (arrows / `j` / `k` to navigate, `Enter` to send `M23 ` + `M24`, `Esc` to dismiss). The rest of the UI continues rendering normally so temperatures and progress remain visible during selection. ## Adding a new backend To swap in Octoprint or Moonraker: 1. Implement a type with these methods: ```go Events() <-chan printer.Event Connect(...) error Disconnect() Snapshot() printer.State SetHotend, SetBed, SetFan, SetFeedrate, SetFlow, BabystepZ, ResetBabystep, Pause, Resume, Cancel, Send ``` 2. Wire it in `cmd/printcontrol/main.go` behind a `-backend` flag. 3. The TUI model only stores `*printer.Printer` today — promote that to an interface, or split the model to accept any implementor. The state model is intentionally a superset of what serial gives us so that backends with richer data (Octoprint knows the slicer-reported time estimate, for example) can populate more fields without new code paths in the UI. ## Known limitations - **Klipper babystep**: we send `M290`, Klipper doesn't implement it. Needs a backend probe (`M115` reports `Klipper` in `FIRMWARE_NAME`) and a switch to `SET_GCODE_OFFSET Z=`. - **No reconnect loop**: if the printer reboots mid-session, the user must press `c` again. Cheap to add but easy to get wrong (auto-reconnect that spams writes during firmware boot can corrupt the print). - **Events channel is bounded (128)**. Under sustained event storms (e.g. Marlin's `M155 S1`) the printer goroutine drops events rather than blocking. The UI catches up on the next event, so the worst-case symptom is a momentarily stale display. - **No tests for the TUI.** Bubble Tea models are testable via `teatest`; worth adding once the key bindings stabilise.