Initial commit for i0T.app and migration here with a fresh repo

This commit is contained in:
2026-05-17 13:51:17 -06:00
commit 0f66fbd9d8
12 changed files with 2146 additions and 0 deletions

296
ARCHITECTURE.md Normal file
View 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 (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.