Files
PrintControl/ARCHITECTURE.md

297 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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*<cur>\s*/\s*<tgt>` |
| SD print progress | `SD printing byte <byte>/<total>` |
| Not currently SD printing | `Not SD printing` |
| SD print finished | `Done printing file` |
| File opened (printer reports it)| `File opened: <filename>` |
| Firmware identification | `FIRMWARE_NAME:<value> SOURCE_CODE_URL:...` |
| SD listing start | `Begin file list` |
| SD listing entry | `<NAME.GCO> <size>` (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 <filename>` |
| Start / resume SD print | `M24` |
| Set hotend N | `M104 T<n> S<temp>` |
| Set bed | `M140 S<temp>` |
| Set fan (0100%) | `M106 S<0..255>` / `M107` |
| Feedrate % | `M220 S<pct>` |
| Flow % | `M221 S<pct>` |
| Babystep Z | `M290 Z<delta>` *(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<line> <gcode>*<checksum>
```
…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 <name>` + `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=<delta>`.
- **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.