Files
PrintControl/ARCHITECTURE.md

12 KiB
Raw Permalink Blame History

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

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*
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:
    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.