Initial commit for i0T.app and migration here with a fresh repo
This commit is contained in:
296
ARCHITECTURE.md
Normal file
296
ARCHITECTURE.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 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 (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:
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user