12 KiB
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) │
└─────────────┘ └────────────┘
- UI goroutine — Bubble Tea's event loop. Owns the model and the screen.
Reads from
printer.Events()via atea.Cmdthat blocks on the channel. - 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. - 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
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 seenok,start, orMarlin). 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* |
| 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 (0–100%) | 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:
- Open the device.
- Sleep ~400 ms so DTR-resetting boards finish booting.
- Write
M115, wait ~1.2 s, writeM115+M105again. - Read for up to 1.5 s, looking for an unambiguous match:
Marlin/FIRMWARE_NAME/RepRapFirmware/Klipper/ a well-formedT:N/NorB:N/Nline.
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:
- Resolve the candidate port list (caller-specified, or every
ttyACM*/ttyUSB*device). - 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. - Apply a 500 ms read timeout on the chosen handle.
- Start the printer goroutine, which spawns its reader goroutine.
- Two seconds later, send the priming block:
M115,M155 S0,M27 S0,M105,M27. TheS0lines 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:
- Cancel the printer goroutine's context.
- Wait for it to drain (
<-p.stopped). - 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 Hztime.Tick, used to refresh the ETA display.waitEvent(printer)— blocks onprinter.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:
- Two side-by-side panels: Status (left), Temperatures (right).
- A full-width Controls panel.
- A full-width Log panel — its height is computed from
m.heightminus the rows the other panels need, so the footer is always reserved. - 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:
- Implement a type with these methods:
Events() <-chan printer.Event Connect(...) error Disconnect() Snapshot() printer.State SetHotend, SetBed, SetFan, SetFeedrate, SetFlow, BabystepZ, ResetBabystep, Pause, Resume, Cancel, Send - Wire it in
cmd/printcontrol/main.gobehind a-backendflag. - The TUI model only stores
*printer.Printertoday — 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 (M115reportsKlipperinFIRMWARE_NAME) and a switch toSET_GCODE_OFFSET Z=<delta>. - No reconnect loop: if the printer reboots mid-session, the user must
press
cagain. 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.