Initial commit for i0T.app and migration here with a fresh repo
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Go build artifacts
|
||||||
|
/go/bin/
|
||||||
|
/go/pkg/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Claude Code workspace (per-user, holds local permission grants)
|
||||||
|
.claude/
|
||||||
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.
|
||||||
224
README.md
Normal file
224
README.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# printcontrol
|
||||||
|
|
||||||
|
A keyboard-driven TUI for monitoring and controlling a 3D printer over USB
|
||||||
|
serial. One static Go binary, zero runtime dependencies, theme-aware via
|
||||||
|
your terminal's 16-colour palette — designed to feel at home on Omarchy
|
||||||
|
alongside `wiremix`, `lazygit`, `lazydocker`, and friends.
|
||||||
|
|
||||||
|
The model: **the printer is in charge during an SD print, we observe and
|
||||||
|
adjust.** Start a print from the LCD, the SD card, or printcontrol's own
|
||||||
|
SD picker — then live-monitor temps, progress, and ETA, and tweak feedrate,
|
||||||
|
flow, fan, hotend / bed setpoints, or babystep Z without ever leaving the
|
||||||
|
keyboard. Quit and come back later; the printer keeps printing and
|
||||||
|
printcontrol rediscovers the in-progress job on reconnect.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ printcontrol · 3D printer TUI ────────────────────────────────────────┐
|
||||||
|
│ ● Status │ ● Temperatures │
|
||||||
|
│ Connection: /dev/ttyUSB1 @ 115200 │ Hotend: 201.3 / 210.0 °C │
|
||||||
|
│ · Marlin 2.1.2 │ Bed: 60.2 / 60.0 °C │
|
||||||
|
│ File: benchy.gcode SD PRINT │ │
|
||||||
|
│ Progress: [██████████··········] │ set hotend: [h] │
|
||||||
|
│ ETA: 1h 12m │ set bed: [b] │
|
||||||
|
├─────────────────────────────────────┴────────────────────────────────────┤
|
||||||
|
│ ● Controls │
|
||||||
|
│ Feedrate: 110% [-/+] [0] Flow: 100% [ [ / ] ] Fan: 30% [f/F] │
|
||||||
|
│ Babystep Z: -0.050 [{ / }] [Z] │
|
||||||
|
│ press [g] to send raw G-code │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ● Log │
|
||||||
|
│ > M105 │
|
||||||
|
│ < ok T:201.3 /210.0 B:60.2 /60.0 @:127 B@:0 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [c] connect [d] disconnect [s] sd start [p] pause [r] resume [x] cancel │
|
||||||
|
│ [h] hotend [b] bed [g] gcode [q] quit │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Build from source — needs Go 1.22+. The repo's canonical install target is
|
||||||
|
`~/.local/bin` (matches Omarchy's PATH); adjust `GOBIN` if you want it
|
||||||
|
elsewhere.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go
|
||||||
|
GOBIN=$HOME/.local/bin go install ./cmd/printcontrol
|
||||||
|
```
|
||||||
|
|
||||||
|
You need permission to open the serial port. On Arch / Omarchy that means
|
||||||
|
membership in the `uucp` group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
groups | grep -q uucp || sudo usermod -aG uucp "$USER" # then log out and back in
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the printer is visible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printcontrol # auto-detect port AND baud
|
||||||
|
printcontrol -port /dev/ttyUSB1 # lock the port, still auto-baud
|
||||||
|
printcontrol -port /dev/ttyUSB1 -baud 250000
|
||||||
|
printcontrol -no-connect # launch without touching serial
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---------------|----------|----------------------------------------------------------|
|
||||||
|
| `-port` | *(auto)* | Serial device path. Scans `/dev/ttyACM*` + `/dev/ttyUSB*` if empty. |
|
||||||
|
| `-baud` | `0` | Baud rate. `0` = auto-detect from `[250000, 115200, 230400, 500000, 57600]`. |
|
||||||
|
| `-no-connect` | `false` | Skip auto-connect on launch. |
|
||||||
|
|
||||||
|
### Auto-detect
|
||||||
|
|
||||||
|
With no flags, printcontrol scans every plausible serial device and probes
|
||||||
|
each candidate baud until something replies with an unmistakably
|
||||||
|
printer-shaped line (`Marlin`, `FIRMWARE_NAME`, `RepRapFirmware`, `Klipper`,
|
||||||
|
or a `T:.../...` temperature report). Two-letter tokens like `"ok"` are
|
||||||
|
*not* accepted as a match because they appear too easily in
|
||||||
|
misaligned-baud garbage. Worst-case probe time is ~15 s; first match wins
|
||||||
|
and the rest are skipped.
|
||||||
|
|
||||||
|
The connect flash at the bottom of the screen reports the chosen
|
||||||
|
combination — e.g. `connected: /dev/ttyUSB1 @ 115200`.
|
||||||
|
|
||||||
|
### Keys (top-level)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----------|-----------------------------------------|
|
||||||
|
| `c` | Connect / reconnect |
|
||||||
|
| `d` | Disconnect |
|
||||||
|
| `s` | List SD card and pick a file to print |
|
||||||
|
| `p` | Pause SD print (`M25`) |
|
||||||
|
| `r` | Resume (`M24`) |
|
||||||
|
| `x` | Cancel SD print (`M524`) |
|
||||||
|
| `h` | Focus hotend setpoint input |
|
||||||
|
| `b` | Focus bed setpoint input |
|
||||||
|
| `g` | Focus raw G-code input |
|
||||||
|
| `+` / `-` | Feedrate ±10% |
|
||||||
|
| `0` | Feedrate back to 100% |
|
||||||
|
| `[` / `]` | Flow ±5% (`M221`) |
|
||||||
|
| `f` / `F` | Fan ±10% |
|
||||||
|
| `{` / `}` | Babystep Z ∓0.05 mm (`M290`, Marlin) |
|
||||||
|
| `Z` | Reset babystep to zero |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
When a text input is focused: type the value, `Enter` to commit, `Esc` to
|
||||||
|
cancel.
|
||||||
|
|
||||||
|
### SD start workflow (key: `s`)
|
||||||
|
|
||||||
|
1. Press `s`. printcontrol sends `M21` (mount SD) and `M20` (list files).
|
||||||
|
2. A picker appears in place of the Log panel.
|
||||||
|
3. `↑`/`↓` (or `k`/`j`) to highlight, `Enter` to start the print, `Esc` to
|
||||||
|
dismiss without printing.
|
||||||
|
4. Selecting a file sends `M23 <filename>` then `M24`. The print runs from
|
||||||
|
the SD card, autonomously of printcontrol.
|
||||||
|
5. You can `q` immediately after — the printer doesn't care. Reconnect any
|
||||||
|
time; the `M27` poll discovers the in-progress print within ~5 seconds
|
||||||
|
and Status / Progress / ETA repopulate.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The picker shows the long filename if the firmware reports one, but
|
||||||
|
always passes the **short 8.3 name** to `M23` (that's what Marlin
|
||||||
|
requires).
|
||||||
|
- Nested folders are listed but can't currently be entered. File a request
|
||||||
|
if you need it.
|
||||||
|
|
||||||
|
## Register with the Omarchy launcher
|
||||||
|
|
||||||
|
Once you're happy with it, drop a desktop entry so SUPER+SPACE finds it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy-tui-install printcontrol printcontrol float <icon-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
`float` makes Hyprland treat it as a floating window — usually the right
|
||||||
|
call for a single-pane TUI. Switch to `tile` if you'd rather have it in
|
||||||
|
your tiling layout.
|
||||||
|
|
||||||
|
## What works · what doesn't
|
||||||
|
|
||||||
|
**Works today**
|
||||||
|
|
||||||
|
- Auto-detect serial port and baud rate
|
||||||
|
- Connect / disconnect over USB serial (multi-port scan)
|
||||||
|
- Live temperatures (hotend, bed, chamber if present) every 2 s
|
||||||
|
- SD-card print detection, progress %, ETA
|
||||||
|
- SD card file listing (`M20`) and start (`M23` + `M24`) from the TUI
|
||||||
|
- Pause / resume / cancel (`M25` / `M24` / `M524`)
|
||||||
|
- Temperature setpoints (`M104` / `M140`)
|
||||||
|
- Fan, feedrate, flow controls (`M106` / `M220` / `M221`)
|
||||||
|
- Babystep Z (`M290` — Marlin)
|
||||||
|
- Raw G-code entry
|
||||||
|
- Firmware identification (`M115`)
|
||||||
|
- Adaptive log panel (footer stays visible on short terminals)
|
||||||
|
|
||||||
|
**Not yet**
|
||||||
|
|
||||||
|
- Host-side print streaming (sending a `.gcode` file from the computer).
|
||||||
|
Needs the line-numbered protocol with checksum + resend handling.
|
||||||
|
- Klipper-specific babystep (`SET_GCODE_OFFSET`). Sends `M290` today, which
|
||||||
|
Klipper rejects.
|
||||||
|
- SD folder navigation (`M20 <dir>`).
|
||||||
|
- Octoprint / Moonraker backends. Architecture supports it; see
|
||||||
|
`ARCHITECTURE.md`.
|
||||||
|
- Persisted profiles (per-printer last setpoints, preferred port).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"no responsive printer on \[...]"** — Auto-detect probed every
|
||||||
|
`ttyACM`/`ttyUSB` device at every common baud and didn't find a printer
|
||||||
|
signature. Most often this means another program (OctoPrint, Klipper's
|
||||||
|
`klippy`, `pronsole`) already owns the port. Quit it first, or pass
|
||||||
|
`-port` explicitly. If you have an unusual baud (e.g. `1000000`), pass
|
||||||
|
`-baud` to override the probe.
|
||||||
|
|
||||||
|
**"could not auto-detect baud rate"** — printcontrol *opened* the port but
|
||||||
|
saw no recognisable reply at any candidate baud. Confirm the device is
|
||||||
|
actually a printer (`screen /dev/ttyUSB1 115200`, then send `M115`); also
|
||||||
|
common: the printer is mid-firmware-flash, or the USB cable is power-only.
|
||||||
|
|
||||||
|
**Temps show but progress stays at 0%** — `M27` only reports SD progress.
|
||||||
|
If you started the print from a host (OctoPrint, etc.) progress won't
|
||||||
|
appear here.
|
||||||
|
|
||||||
|
**Babystep does nothing** — You're likely on Klipper, which doesn't
|
||||||
|
implement `M290`. Klipper support is on the roadmap.
|
||||||
|
|
||||||
|
**Mangled lines like `TT::57.4957.49`** — This was the symptom of Marlin's
|
||||||
|
auto-temperature report colliding with our explicit `M105` poll. Fixed
|
||||||
|
upstream: the prime block now sends `M155 S0` / `M27 S0` to disable
|
||||||
|
auto-reports.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
printcontrol/
|
||||||
|
├── go/
|
||||||
|
│ ├── cmd/printcontrol/main.go # entry point, flag parsing
|
||||||
|
│ ├── internal/printer/ # serial driver + G-code state machine
|
||||||
|
│ │ ├── state.go # State / PrintState / TempPair / SDFile
|
||||||
|
│ │ ├── parse.go # line parsing (M105, M27, M115, M20, ...)
|
||||||
|
│ │ ├── printer.go # Connect/Disconnect, owning goroutine, probe
|
||||||
|
│ │ └── parse_test.go
|
||||||
|
│ └── internal/tui/ # Bubble Tea model + Lipgloss styling
|
||||||
|
│ ├── model.go # Model, key handling, SD picker, layout
|
||||||
|
│ └── style.go
|
||||||
|
├── README.md
|
||||||
|
└── ARCHITECTURE.md # protocol + design notes
|
||||||
|
```
|
||||||
|
|
||||||
|
See `ARCHITECTURE.md` for the wire protocol, the state model, the
|
||||||
|
auto-detect probe strategy, and why things are wired the way they are.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0-or-later (matches Printrun, which inspired the protocol layer).
|
||||||
30
go/cmd/printcontrol/main.go
Normal file
30
go/cmd/printcontrol/main.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lwoodard/printcontrol/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := flag.String("port", "", "Serial port (auto-detected if empty)")
|
||||||
|
baud := flag.Int("baud", 0, "Baud rate (0 = auto-detect)")
|
||||||
|
noConnect := flag.Bool("no-connect", false, "Don't auto-connect on launch")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
m := tui.New(tui.Config{
|
||||||
|
Port: *port,
|
||||||
|
Baud: *baud,
|
||||||
|
AutoConnect: !*noConnect,
|
||||||
|
})
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "printcontrol:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
go/go.mod
Normal file
30
go/go.mod
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
module github.com/lwoodard/printcontrol
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
go.bug.st/serial v1.6.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/creack/goselect v0.1.2 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
55
go/go.sum
Normal file
55
go/go.sum
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
|
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||||
|
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
142
go/internal/printer/parse.go
Normal file
142
go/internal/printer/parse.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package printer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tempRe = regexp.MustCompile(`(T\d*|B|C):\s*(-?\d+(?:\.\d+)?)\s*/\s*(-?\d+(?:\.\d+)?)`)
|
||||||
|
sdRe = regexp.MustCompile(`(?i)SD printing byte\s+(\d+)\s*/\s*(\d+)`)
|
||||||
|
notSDRe = regexp.MustCompile(`(?i)Not SD printing`)
|
||||||
|
sdDoneRe = regexp.MustCompile(`(?i)Done printing file`)
|
||||||
|
fileOpen = regexp.MustCompile(`(?i)File opened:\s*(\S+)`)
|
||||||
|
sdListStart = regexp.MustCompile(`(?i)^Begin file list`)
|
||||||
|
sdListEnd = regexp.MustCompile(`(?i)^End file list`)
|
||||||
|
sdListLongRe = regexp.MustCompile(`"([^"]+)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse one line of printer output and mutate s. Returns true if anything changed.
|
||||||
|
func (s *State) Parse(line string) bool {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for _, m := range tempRe.FindAllStringSubmatch(line, -1) {
|
||||||
|
tool := m[1]
|
||||||
|
cur, _ := strconv.ParseFloat(m[2], 64)
|
||||||
|
tgt, _ := strconv.ParseFloat(m[3], 64)
|
||||||
|
if s.Temps == nil {
|
||||||
|
s.Temps = map[string]TempPair{}
|
||||||
|
}
|
||||||
|
s.Temps[tool] = TempPair{Current: cur, Target: tgt}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Firmware == "" {
|
||||||
|
if idx := strings.Index(line, "FIRMWARE_NAME:"); idx >= 0 {
|
||||||
|
rest := line[idx+len("FIRMWARE_NAME:"):]
|
||||||
|
// Cut at the next CAPS_TOKEN: marker that Marlin emits after FIRMWARE_NAME
|
||||||
|
for _, sep := range []string{" SOURCE_CODE_URL:", " PROTOCOL_VERSION:", " MACHINE_TYPE:", " EXTRUDER_COUNT:"} {
|
||||||
|
if i := strings.Index(rest, sep); i >= 0 {
|
||||||
|
rest = rest[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Firmware = strings.TrimSpace(rest)
|
||||||
|
if len(s.Firmware) > 48 {
|
||||||
|
s.Firmware = s.Firmware[:48]
|
||||||
|
}
|
||||||
|
if s.Firmware != "" {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := fileOpen.FindStringSubmatch(line); m != nil {
|
||||||
|
s.SDFilename = m[1]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SD card directory listing (M20). The firmware emits "Begin file list",
|
||||||
|
// one entry per line, then "End file list". While we're inside that
|
||||||
|
// block, every non-marker line is a file entry; we accumulate into
|
||||||
|
// SDFiles. When the block ends we flip SDListing off and leave the
|
||||||
|
// completed slice in place for the UI to consume.
|
||||||
|
if sdListStart.MatchString(line) {
|
||||||
|
s.SDListing = true
|
||||||
|
s.SDFiles = s.SDFiles[:0]
|
||||||
|
changed = true
|
||||||
|
} else if sdListEnd.MatchString(line) {
|
||||||
|
if s.SDListing {
|
||||||
|
s.SDListing = false
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if s.SDListing {
|
||||||
|
if f, ok := parseSDFileEntry(line); ok {
|
||||||
|
s.SDFiles = append(s.SDFiles, f)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := sdRe.FindStringSubmatch(line); m != nil {
|
||||||
|
byteN, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
total, _ := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
s.SDByte = byteN
|
||||||
|
s.SDTotal = total
|
||||||
|
if byteN > 0 && total > 0 {
|
||||||
|
if s.PrintState != StateSDPrinting && s.PrintState != StatePaused {
|
||||||
|
s.PrintState = StateSDPrinting
|
||||||
|
if s.SDStart.IsZero() {
|
||||||
|
s.SDStart = nowFn()
|
||||||
|
}
|
||||||
|
} else if s.SDStart.IsZero() {
|
||||||
|
s.SDStart = nowFn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
} else if notSDRe.MatchString(line) {
|
||||||
|
if s.PrintState == StateSDPrinting {
|
||||||
|
s.PrintState = StateIdle
|
||||||
|
s.SDByte, s.SDTotal = 0, 0
|
||||||
|
s.SDStart = zeroTime
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if sdDoneRe.MatchString(line) {
|
||||||
|
s.PrintState = StateIdle
|
||||||
|
s.SDByte = s.SDTotal
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSDFileEntry tries to read a single line from inside the
|
||||||
|
// "Begin file list" / "End file list" block. Marlin emits
|
||||||
|
// "FILENAME.GCO 123456" or "FILENAME.GCO 123456 \"Long Name.gcode\"".
|
||||||
|
// Directories are reported with a trailing "/" — we surface them as
|
||||||
|
// entries too so the user can spot them, even though M23 won't enter them.
|
||||||
|
func parseSDFileEntry(line string) (SDFile, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return SDFile{}, false
|
||||||
|
}
|
||||||
|
// Strip a trailing long-filename if present, capture it.
|
||||||
|
long := ""
|
||||||
|
if m := sdListLongRe.FindStringSubmatch(line); m != nil {
|
||||||
|
long = m[1]
|
||||||
|
line = strings.TrimSpace(line[:strings.Index(line, "\"")])
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return SDFile{}, false
|
||||||
|
}
|
||||||
|
name := fields[0]
|
||||||
|
var size int64
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
size, _ = strconv.ParseInt(fields[len(fields)-1], 10, 64)
|
||||||
|
}
|
||||||
|
return SDFile{Name: name, LongName: long, Size: size}, true
|
||||||
|
}
|
||||||
81
go/internal/printer/parse_test.go
Normal file
81
go/internal/printer/parse_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package printer
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTemps(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
s.Parse("ok T:201.3 /210.0 B:60.2 /60.0 @:127 B@:0")
|
||||||
|
if got := s.Temps["T"]; got.Current != 201.3 || got.Target != 210.0 {
|
||||||
|
t.Fatalf("hotend got %+v", got)
|
||||||
|
}
|
||||||
|
if got := s.Temps["B"]; got.Current != 60.2 || got.Target != 60.0 {
|
||||||
|
t.Fatalf("bed got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSDProgress(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
s.Parse("SD printing byte 12345/100000")
|
||||||
|
if s.SDByte != 12345 || s.SDTotal != 100000 {
|
||||||
|
t.Fatalf("got byte=%d total=%d", s.SDByte, s.SDTotal)
|
||||||
|
}
|
||||||
|
if s.PrintState != StateSDPrinting {
|
||||||
|
t.Fatalf("expected SD printing state, got %v", s.PrintState)
|
||||||
|
}
|
||||||
|
if p := s.Progress(); p < 0.12 || p > 0.13 {
|
||||||
|
t.Fatalf("progress %f", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNotSDPrinting(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
s.SDByte, s.SDTotal = 1, 2
|
||||||
|
s.PrintState = StateSDPrinting
|
||||||
|
s.Parse("Not SD printing")
|
||||||
|
if s.PrintState != StateIdle {
|
||||||
|
t.Fatalf("expected idle, got %v", s.PrintState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFirmware(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
s.Parse("FIRMWARE_NAME:Marlin 2.1.2 SOURCE_CODE_URL:github.com/foo")
|
||||||
|
if s.Firmware != "Marlin 2.1.2" {
|
||||||
|
t.Fatalf("got firmware %q", s.Firmware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFileOpened(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
s.Parse("File opened: benchy.gcode Size: 1234567")
|
||||||
|
if s.SDFilename != "benchy.gcode" {
|
||||||
|
t.Fatalf("got %q", s.SDFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSDFileListing(t *testing.T) {
|
||||||
|
s := NewState()
|
||||||
|
for _, line := range []string{
|
||||||
|
"Begin file list",
|
||||||
|
"BENCHY.GCO 1234567",
|
||||||
|
"XYZ_CAL.GCO 50000 \"XYZ Calibration.gcode\"",
|
||||||
|
"End file list",
|
||||||
|
"ok",
|
||||||
|
} {
|
||||||
|
s.Parse(line)
|
||||||
|
}
|
||||||
|
if s.SDListing {
|
||||||
|
t.Fatalf("expected SDListing false after End")
|
||||||
|
}
|
||||||
|
if len(s.SDFiles) != 2 {
|
||||||
|
t.Fatalf("got %d files: %+v", len(s.SDFiles), s.SDFiles)
|
||||||
|
}
|
||||||
|
if s.SDFiles[0].Name != "BENCHY.GCO" || s.SDFiles[0].Size != 1234567 {
|
||||||
|
t.Fatalf("entry 0: %+v", s.SDFiles[0])
|
||||||
|
}
|
||||||
|
if s.SDFiles[1].Name != "XYZ_CAL.GCO" ||
|
||||||
|
s.SDFiles[1].Size != 50000 ||
|
||||||
|
s.SDFiles[1].LongName != "XYZ Calibration.gcode" {
|
||||||
|
t.Fatalf("entry 1: %+v", s.SDFiles[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
529
go/internal/printer/printer.go
Normal file
529
go/internal/printer/printer.go
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
package printer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is a state snapshot published whenever the printer state changes,
|
||||||
|
// plus a raw recv line (RecvLine != "" means "log this; State unchanged is allowed").
|
||||||
|
type Event struct {
|
||||||
|
State State
|
||||||
|
RecvLine string
|
||||||
|
SentLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Printer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state State
|
||||||
|
port serial.Port
|
||||||
|
events chan Event
|
||||||
|
writes chan string // outbound G-code (best-effort, non-blocking)
|
||||||
|
cancel context.CancelFunc
|
||||||
|
stopped chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Printer {
|
||||||
|
return &Printer{
|
||||||
|
state: NewState(),
|
||||||
|
events: make(chan Event, 128),
|
||||||
|
writes: make(chan string, 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Events() <-chan Event { return p.events }
|
||||||
|
|
||||||
|
func (p *Printer) Snapshot() State {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPorts returns plausible printer serial devices.
|
||||||
|
func ListPorts() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, pat := range []string{"/dev/ttyACM*", "/dev/ttyUSB*"} {
|
||||||
|
m, _ := filepath.Glob(pat)
|
||||||
|
out = append(out, m...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonBauds is the ordered list of baud rates tried during auto-detection.
|
||||||
|
// 250000 is Marlin's historical AVR default; 115200 covers STM32 builds,
|
||||||
|
// Klipper-prep firmwares, and RepRapFirmware.
|
||||||
|
var CommonBauds = []int{250000, 115200, 230400, 500000, 57600}
|
||||||
|
|
||||||
|
// probeBaud opens the port at each candidate baud and looks for a
|
||||||
|
// recognisable Marlin/RepRap reply. Returns the working baud and an open
|
||||||
|
// port handle on success.
|
||||||
|
//
|
||||||
|
// We deliberately require an *unambiguous* match (firmware name or a
|
||||||
|
// well-formed temperature line). Short tokens like "ok" or "start" appear
|
||||||
|
// too easily in misaligned-baud garbage and would yield a false positive,
|
||||||
|
// so they are not accepted on their own.
|
||||||
|
func probeBaud(portName string, candidates []int) (int, serial.Port, error) {
|
||||||
|
tempLine := regexp.MustCompile(`(?i)(?:^|\s)(?:T\d*|B|C):\s*-?\d+(?:\.\d+)?\s*/\s*-?\d+`)
|
||||||
|
|
||||||
|
for _, b := range candidates {
|
||||||
|
sp, err := serial.Open(portName, &serial.Mode{BaudRate: b})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Many boards reset on DTR assertion and need ~2 s to boot.
|
||||||
|
// Wait, then prod the firmware twice (once early in case the boot
|
||||||
|
// already finished, once later in case the first prod was eaten).
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
_, _ = sp.Write([]byte("\nM115\n"))
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
_, _ = sp.Write([]byte("\nM115\nM105\n"))
|
||||||
|
|
||||||
|
deadline := time.Now().Add(1500 * time.Millisecond)
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
var acc []byte
|
||||||
|
ok := false
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, _ := sp.Read(buf)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc = append(acc, buf[:n]...)
|
||||||
|
low := strings.ToLower(string(acc))
|
||||||
|
if strings.Contains(low, "marlin") ||
|
||||||
|
strings.Contains(low, "firmware_name") ||
|
||||||
|
strings.Contains(low, "reprapfirmware") ||
|
||||||
|
strings.Contains(low, "klipper") ||
|
||||||
|
tempLine.MatchString(string(acc)) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Cap accumulator so we don't grow unbounded on garbage.
|
||||||
|
if len(acc) > 4096 {
|
||||||
|
acc = acc[len(acc)-2048:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return b, sp, nil
|
||||||
|
}
|
||||||
|
_ = sp.Close()
|
||||||
|
}
|
||||||
|
return 0, nil, fmt.Errorf("could not auto-detect baud rate; tried %v", candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Connect(portName string, baud int) error {
|
||||||
|
p.Disconnect()
|
||||||
|
|
||||||
|
// Candidate ports: caller-specified, or everything that looks like a
|
||||||
|
// printer. We probe each in turn so that systems with multiple ACM/USB
|
||||||
|
// devices (modems, debug probes, etc.) don't trap us on the wrong one.
|
||||||
|
var candidates []string
|
||||||
|
if portName != "" {
|
||||||
|
candidates = []string{portName}
|
||||||
|
} else {
|
||||||
|
candidates = ListPorts()
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return fmt.Errorf("no serial port found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sp serial.Port
|
||||||
|
var chosenPort string
|
||||||
|
if baud <= 0 {
|
||||||
|
var lastErr error
|
||||||
|
for _, pn := range candidates {
|
||||||
|
detected, opened, err := probeBaud(pn, CommonBauds)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp = opened
|
||||||
|
baud = detected
|
||||||
|
chosenPort = pn
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sp == nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("no responsive printer on %v: %w", candidates, lastErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no responsive printer on %v", candidates)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Explicit baud: still try each candidate port until one opens and
|
||||||
|
// looks alive at this baud.
|
||||||
|
var lastErr error
|
||||||
|
for _, pn := range candidates {
|
||||||
|
opened, err := serial.Open(pn, &serial.Mode{BaudRate: baud})
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp = opened
|
||||||
|
chosenPort = pn
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sp == nil {
|
||||||
|
return fmt.Errorf("could not open any of %v: %w", candidates, lastErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
portName = chosenPort
|
||||||
|
_ = sp.SetReadTimeout(500 * time.Millisecond)
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.port = sp
|
||||||
|
p.state.Connected = true
|
||||||
|
p.state.Port = portName
|
||||||
|
p.state.Baud = baud
|
||||||
|
p.state.ErrorMsg = ""
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p.cancel = cancel
|
||||||
|
p.stopped = make(chan struct{})
|
||||||
|
|
||||||
|
go p.run(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Disconnect() {
|
||||||
|
if p.cancel != nil {
|
||||||
|
p.cancel()
|
||||||
|
<-p.stopped
|
||||||
|
p.cancel = nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.port != nil {
|
||||||
|
_ = p.port.Close()
|
||||||
|
p.port = nil
|
||||||
|
}
|
||||||
|
p.state.Connected = false
|
||||||
|
p.state.Online = false
|
||||||
|
p.state.PrintState = StateIdle
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the connection goroutine. It owns the serial port for the duration
|
||||||
|
// of the connection and serialises reads/writes through this single goroutine.
|
||||||
|
func (p *Printer) run(ctx context.Context) {
|
||||||
|
defer close(p.stopped)
|
||||||
|
|
||||||
|
port := p.port
|
||||||
|
if port == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(port)
|
||||||
|
lineCh := make(chan string, 32)
|
||||||
|
|
||||||
|
// reader goroutine
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
close(lineCh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// timeout — ReadString returns partial+err; just retry
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case lineCh <- line:
|
||||||
|
case <-ctx.Done():
|
||||||
|
close(lineCh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Probe — these prime auto-reporting on firmwares that support it.
|
||||||
|
primed := false
|
||||||
|
primeAfter := time.NewTimer(2 * time.Second)
|
||||||
|
defer primeAfter.Stop()
|
||||||
|
|
||||||
|
pollTemp := time.NewTicker(2 * time.Second)
|
||||||
|
defer pollTemp.Stop()
|
||||||
|
pollSD := time.NewTicker(5 * time.Second)
|
||||||
|
defer pollSD.Stop()
|
||||||
|
|
||||||
|
prime := func() {
|
||||||
|
if primed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
primed = true
|
||||||
|
p.writeRaw("M115")
|
||||||
|
// Disable any auto-reporting Marlin may have on — our own polling is
|
||||||
|
// the single source of truth. Otherwise the two streams collide and
|
||||||
|
// produce mangled output like "TT::57.4957.49".
|
||||||
|
p.writeRaw("M155 S0")
|
||||||
|
p.writeRaw("M27 S0")
|
||||||
|
// First explicit poll so users see data fast
|
||||||
|
p.writeRaw("M105")
|
||||||
|
p.writeRaw("M27")
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-primeAfter.C:
|
||||||
|
// Some firmwares emit "start" / "Marlin" before being ready; just prime now.
|
||||||
|
prime()
|
||||||
|
// Mark online if we got *anything* by now
|
||||||
|
p.setOnline(true)
|
||||||
|
|
||||||
|
case line, ok := <-lineCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// "start" / "Marlin" / "ok" gates online
|
||||||
|
low := strings.ToLower(line)
|
||||||
|
if strings.Contains(low, "start") || strings.HasPrefix(low, "marlin") {
|
||||||
|
prime()
|
||||||
|
p.setOnline(true)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(low, "ok") {
|
||||||
|
p.setOnline(true)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(low, "error") || strings.HasPrefix(low, "!!") {
|
||||||
|
p.setError(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.Parse(line)
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
p.publish(Event{RecvLine: line, State: snap})
|
||||||
|
|
||||||
|
case <-pollTemp.C:
|
||||||
|
if !primed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.writeRaw("M105")
|
||||||
|
|
||||||
|
case <-pollSD.C:
|
||||||
|
if !primed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.writeRaw("M27")
|
||||||
|
|
||||||
|
case gcode := <-p.writes:
|
||||||
|
p.writeRaw(gcode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) writeRaw(gcode string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
port := p.port
|
||||||
|
p.mu.Unlock()
|
||||||
|
if port == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(gcode)
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := port.Write([]byte(line + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
p.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.publish(Event{SentLine: line, State: p.Snapshot()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send enqueues a raw G-code line. Non-blocking; drops if buffer full.
|
||||||
|
func (p *Printer) Send(gcode string) {
|
||||||
|
select {
|
||||||
|
case p.writes <- gcode:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- high-level commands --------------------------------------------------
|
||||||
|
|
||||||
|
func (p *Printer) SetHotend(temp int, tool int) { p.Send(fmt.Sprintf("M104 T%d S%d", tool, temp)) }
|
||||||
|
func (p *Printer) SetBed(temp int) { p.Send(fmt.Sprintf("M140 S%d", temp)) }
|
||||||
|
|
||||||
|
func (p *Printer) SetFan(pct int) {
|
||||||
|
pct = clamp(pct, 0, 100)
|
||||||
|
if pct == 0 {
|
||||||
|
p.Send("M107")
|
||||||
|
} else {
|
||||||
|
p.Send(fmt.Sprintf("M106 S%d", pct*255/100))
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.FanPct = pct
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) SetFeedrate(pct int) {
|
||||||
|
pct = clamp(pct, 10, 300)
|
||||||
|
p.Send(fmt.Sprintf("M220 S%d", pct))
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.FeedratePct = pct
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) SetFlow(pct int) {
|
||||||
|
pct = clamp(pct, 50, 150)
|
||||||
|
p.Send(fmt.Sprintf("M221 S%d", pct))
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.FlowPct = pct
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) BabystepZ(delta float64) {
|
||||||
|
p.Send(fmt.Sprintf("M290 Z%.3f", delta))
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.BabystepZ = roundTo(p.state.BabystepZ+delta, 3)
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) ResetBabystep() {
|
||||||
|
p.mu.Lock()
|
||||||
|
delta := -p.state.BabystepZ
|
||||||
|
p.state.BabystepZ = 0
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.Send(fmt.Sprintf("M290 Z%.3f", delta))
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Pause() {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.state.PrintState == StateSDPrinting {
|
||||||
|
p.state.PrintState = StatePaused
|
||||||
|
}
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.Send("M25")
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Resume() {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.state.PrintState == StatePaused && p.state.SDTotal > 0 {
|
||||||
|
p.state.PrintState = StateSDPrinting
|
||||||
|
}
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.Send("M24")
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSDFiles asks the printer to enumerate its SD card. The reply is
|
||||||
|
// parsed asynchronously; the next State snapshot whose SDListing flips
|
||||||
|
// false carries the complete listing in SDFiles.
|
||||||
|
func (p *Printer) ListSDFiles() {
|
||||||
|
p.Send("M21") // init / mount SD (no-op if already mounted)
|
||||||
|
p.Send("M20")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSDPrint selects the given filename on the SD card and starts it.
|
||||||
|
// M23 selects, M24 begins. Filename must be the 8.3 short name that
|
||||||
|
// Marlin's M20 listing reports.
|
||||||
|
func (p *Printer) StartSDPrint(filename string) {
|
||||||
|
filename = strings.TrimSpace(filename)
|
||||||
|
if filename == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Send("M23 " + filename)
|
||||||
|
p.Send("M24")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) Cancel() {
|
||||||
|
p.Send("M524")
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.PrintState = StateIdle
|
||||||
|
p.state.SDByte, p.state.SDTotal = 0, 0
|
||||||
|
p.state.SDStart = zeroTime
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- internals ------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *Printer) setOnline(v bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.state.Online == v {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.state.Online = v
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) setError(msg string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.state.ErrorMsg = msg
|
||||||
|
p.state.PrintState = StateError
|
||||||
|
snap := p.state
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.publish(Event{State: snap, RecvLine: "ERR " + msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Printer) publish(e Event) {
|
||||||
|
select {
|
||||||
|
case p.events <- e:
|
||||||
|
default:
|
||||||
|
// drop on full channel — UI will catch up on the next event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTo(v float64, places int) float64 {
|
||||||
|
pow := 1.0
|
||||||
|
for i := 0; i < places; i++ {
|
||||||
|
pow *= 10
|
||||||
|
}
|
||||||
|
return float64(int64(v*pow+0.5*sign(v))) / pow
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
115
go/internal/printer/state.go
Normal file
115
go/internal/printer/state.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package printer
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type PrintState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateIdle PrintState = iota
|
||||||
|
StateSDPrinting
|
||||||
|
StateHostPrinting
|
||||||
|
StatePaused
|
||||||
|
StateError
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p PrintState) String() string {
|
||||||
|
switch p {
|
||||||
|
case StateIdle:
|
||||||
|
return "IDLE"
|
||||||
|
case StateSDPrinting:
|
||||||
|
return "SD PRINTING"
|
||||||
|
case StateHostPrinting:
|
||||||
|
return "PRINTING"
|
||||||
|
case StatePaused:
|
||||||
|
return "PAUSED"
|
||||||
|
case StateError:
|
||||||
|
return "ERROR"
|
||||||
|
default:
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TempPair struct {
|
||||||
|
Current float64
|
||||||
|
Target float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type SDFile struct {
|
||||||
|
Name string // 8.3 short filename, what M23 takes
|
||||||
|
LongName string // long filename if firmware reports one
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Connected bool
|
||||||
|
Online bool
|
||||||
|
Firmware string
|
||||||
|
Port string
|
||||||
|
Baud int
|
||||||
|
|
||||||
|
PrintState PrintState
|
||||||
|
ErrorMsg string
|
||||||
|
|
||||||
|
Temps map[string]TempPair
|
||||||
|
|
||||||
|
SDByte int64
|
||||||
|
SDTotal int64
|
||||||
|
SDFilename string
|
||||||
|
SDStart time.Time
|
||||||
|
|
||||||
|
FeedratePct int
|
||||||
|
FlowPct int
|
||||||
|
FanPct int
|
||||||
|
BabystepZ float64
|
||||||
|
|
||||||
|
// SD card listing. SDListing is true while M20 output is being
|
||||||
|
// collected; SDFiles is the most recently completed listing.
|
||||||
|
SDFiles []SDFile
|
||||||
|
SDListing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewState() State {
|
||||||
|
return State{
|
||||||
|
Temps: map[string]TempPair{},
|
||||||
|
FeedratePct: 100,
|
||||||
|
FlowPct: 100,
|
||||||
|
FanPct: 0,
|
||||||
|
PrintState: StateIdle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Progress() float64 {
|
||||||
|
if s.SDTotal <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
p := float64(s.SDByte) / float64(s.SDTotal)
|
||||||
|
if p > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ETA() (time.Duration, bool) {
|
||||||
|
if s.PrintState != StateSDPrinting || s.SDByte <= 0 || s.SDStart.IsZero() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
elapsed := nowFn().Sub(s.SDStart)
|
||||||
|
if elapsed < 5*time.Second {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
rate := float64(s.SDByte) / elapsed.Seconds()
|
||||||
|
if rate <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
remaining := float64(s.SDTotal - s.SDByte)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
return time.Duration(remaining/rate) * time.Second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// indirection so tests can fix time
|
||||||
|
var (
|
||||||
|
nowFn = time.Now
|
||||||
|
zeroTime = time.Time{}
|
||||||
|
)
|
||||||
596
go/internal/tui/model.go
Normal file
596
go/internal/tui/model.go
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/lwoodard/printcontrol/internal/printer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
Baud int
|
||||||
|
AutoConnect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type focusArea int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusNone focusArea = iota
|
||||||
|
focusHotendInput
|
||||||
|
focusBedInput
|
||||||
|
focusGcodeInput
|
||||||
|
focusSDPicker
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
cfg Config
|
||||||
|
printer *printer.Printer
|
||||||
|
state printer.State
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
|
||||||
|
hotendInput string
|
||||||
|
bedInput string
|
||||||
|
gcodeInput string
|
||||||
|
focus focusArea
|
||||||
|
|
||||||
|
// SD picker state. Populated when the user opens it with [s];
|
||||||
|
// refreshed whenever the printer reports a fresh listing.
|
||||||
|
sdPickerFiles []printer.SDFile
|
||||||
|
sdPickerIdx int
|
||||||
|
sdPickerWait bool // waiting for M20 reply
|
||||||
|
|
||||||
|
log []string
|
||||||
|
logMax int
|
||||||
|
flashUntil time.Time
|
||||||
|
flashMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Model {
|
||||||
|
return &Model{
|
||||||
|
cfg: cfg,
|
||||||
|
printer: printer.New(),
|
||||||
|
state: printer.NewState(),
|
||||||
|
logMax: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- bubbletea Model interface ---------------------------------------------
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
cmds := []tea.Cmd{tickCmd(), waitEvent(m.printer)}
|
||||||
|
if m.cfg.AutoConnect {
|
||||||
|
cmds = append(cmds, m.connectCmd())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tickMsg time.Time
|
||||||
|
type eventMsg printer.Event
|
||||||
|
type connectResultMsg struct{ err error }
|
||||||
|
|
||||||
|
func tickCmd() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitEvent(p *printer.Printer) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
evt, ok := <-p.Events()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return eventMsg(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) connectCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
err := m.printer.Connect(m.cfg.Port, m.cfg.Baud)
|
||||||
|
return connectResultMsg{err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tickMsg:
|
||||||
|
return m, tickCmd()
|
||||||
|
|
||||||
|
case eventMsg:
|
||||||
|
evt := printer.Event(msg)
|
||||||
|
if evt.State.Port != "" || evt.State.Connected || evt.State.PrintState != 0 || len(evt.State.Temps) > 0 {
|
||||||
|
m.state = evt.State
|
||||||
|
}
|
||||||
|
if evt.RecvLine != "" {
|
||||||
|
m.appendLog("< " + evt.RecvLine)
|
||||||
|
}
|
||||||
|
if evt.SentLine != "" {
|
||||||
|
m.appendLog("> " + evt.SentLine)
|
||||||
|
}
|
||||||
|
// If we were waiting on an M20 listing and one just completed,
|
||||||
|
// populate the picker.
|
||||||
|
if m.sdPickerWait && !evt.State.SDListing && len(evt.State.SDFiles) > 0 {
|
||||||
|
m.sdPickerFiles = append(m.sdPickerFiles[:0], evt.State.SDFiles...)
|
||||||
|
m.sdPickerIdx = 0
|
||||||
|
m.sdPickerWait = false
|
||||||
|
m.focus = focusSDPicker
|
||||||
|
}
|
||||||
|
return m, waitEvent(m.printer)
|
||||||
|
|
||||||
|
case connectResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.flash("connect failed: " + msg.err.Error())
|
||||||
|
} else {
|
||||||
|
snap := m.printer.Snapshot()
|
||||||
|
m.state = snap
|
||||||
|
m.flash(fmt.Sprintf("connected: %s @ %d", snap.Port, snap.Baud))
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
// SD picker swallows all key input while open: arrows to navigate,
|
||||||
|
// Enter to start the highlighted print, Esc to dismiss.
|
||||||
|
if m.focus == focusSDPicker {
|
||||||
|
switch k.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.focus = focusNone
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyUp:
|
||||||
|
if m.sdPickerIdx > 0 {
|
||||||
|
m.sdPickerIdx--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyDown:
|
||||||
|
if m.sdPickerIdx < len(m.sdPickerFiles)-1 {
|
||||||
|
m.sdPickerIdx++
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if m.sdPickerIdx < len(m.sdPickerFiles) {
|
||||||
|
f := m.sdPickerFiles[m.sdPickerIdx]
|
||||||
|
m.printer.StartSDPrint(f.Name)
|
||||||
|
m.flash("start: " + f.Name)
|
||||||
|
}
|
||||||
|
m.focus = focusNone
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// Letters j/k as a vim-style fallback for users without arrow keys.
|
||||||
|
switch k.String() {
|
||||||
|
case "k":
|
||||||
|
if m.sdPickerIdx > 0 {
|
||||||
|
m.sdPickerIdx--
|
||||||
|
}
|
||||||
|
case "j":
|
||||||
|
if m.sdPickerIdx < len(m.sdPickerFiles)-1 {
|
||||||
|
m.sdPickerIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a text input is focused, capture printable text + enter/esc
|
||||||
|
if m.focus != focusNone {
|
||||||
|
switch k.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.focus = focusNone
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
return m, m.commitInput()
|
||||||
|
case tea.KeyBackspace, tea.KeyDelete:
|
||||||
|
m.editInput(func(s string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:len(s)-1]
|
||||||
|
})
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyRunes, tea.KeySpace:
|
||||||
|
m.editInput(func(s string) string { return s + k.String() })
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch k.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
m.printer.Disconnect()
|
||||||
|
return m, tea.Quit
|
||||||
|
case "c":
|
||||||
|
m.flash("connecting…")
|
||||||
|
return m, m.connectCmd()
|
||||||
|
case "d":
|
||||||
|
m.printer.Disconnect()
|
||||||
|
return m, nil
|
||||||
|
case "p":
|
||||||
|
m.printer.Pause()
|
||||||
|
case "r":
|
||||||
|
m.printer.Resume()
|
||||||
|
case "x":
|
||||||
|
m.printer.Cancel()
|
||||||
|
case "h":
|
||||||
|
m.focus = focusHotendInput
|
||||||
|
case "b":
|
||||||
|
m.focus = focusBedInput
|
||||||
|
case "g":
|
||||||
|
m.focus = focusGcodeInput
|
||||||
|
case "s":
|
||||||
|
m.printer.ListSDFiles()
|
||||||
|
m.sdPickerWait = true
|
||||||
|
m.flash("listing SD card…")
|
||||||
|
case "f":
|
||||||
|
m.printer.SetFan(m.state.FanPct + 10)
|
||||||
|
case "F":
|
||||||
|
m.printer.SetFan(m.state.FanPct - 10)
|
||||||
|
case "+", "=":
|
||||||
|
m.printer.SetFeedrate(m.state.FeedratePct + 10)
|
||||||
|
case "-", "_":
|
||||||
|
m.printer.SetFeedrate(m.state.FeedratePct - 10)
|
||||||
|
case "0":
|
||||||
|
m.printer.SetFeedrate(100)
|
||||||
|
case "[":
|
||||||
|
m.printer.SetFlow(m.state.FlowPct - 5)
|
||||||
|
case "]":
|
||||||
|
m.printer.SetFlow(m.state.FlowPct + 5)
|
||||||
|
case "{":
|
||||||
|
m.printer.BabystepZ(-0.05)
|
||||||
|
case "}":
|
||||||
|
m.printer.BabystepZ(0.05)
|
||||||
|
case "Z":
|
||||||
|
m.printer.ResetBabystep()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) editInput(fn func(string) string) {
|
||||||
|
switch m.focus {
|
||||||
|
case focusHotendInput:
|
||||||
|
m.hotendInput = fn(m.hotendInput)
|
||||||
|
case focusBedInput:
|
||||||
|
m.bedInput = fn(m.bedInput)
|
||||||
|
case focusGcodeInput:
|
||||||
|
m.gcodeInput = fn(m.gcodeInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) commitInput() tea.Cmd {
|
||||||
|
switch m.focus {
|
||||||
|
case focusHotendInput:
|
||||||
|
if v, err := strconv.Atoi(strings.TrimSpace(m.hotendInput)); err == nil {
|
||||||
|
m.printer.SetHotend(v, 0)
|
||||||
|
m.flash(fmt.Sprintf("hotend → %d °C", v))
|
||||||
|
}
|
||||||
|
m.hotendInput = ""
|
||||||
|
case focusBedInput:
|
||||||
|
if v, err := strconv.Atoi(strings.TrimSpace(m.bedInput)); err == nil {
|
||||||
|
m.printer.SetBed(v)
|
||||||
|
m.flash(fmt.Sprintf("bed → %d °C", v))
|
||||||
|
}
|
||||||
|
m.bedInput = ""
|
||||||
|
case focusGcodeInput:
|
||||||
|
if line := strings.TrimSpace(m.gcodeInput); line != "" {
|
||||||
|
m.printer.Send(line)
|
||||||
|
}
|
||||||
|
m.gcodeInput = ""
|
||||||
|
}
|
||||||
|
m.focus = focusNone
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) flash(msg string) {
|
||||||
|
m.flashMsg = msg
|
||||||
|
m.flashUntil = time.Now().Add(3 * time.Second)
|
||||||
|
m.appendLog("· " + msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) appendLog(line string) {
|
||||||
|
m.log = append(m.log, line)
|
||||||
|
if len(m.log) > m.logMax {
|
||||||
|
m.log = m.log[len(m.log)-m.logMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- view ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
if m.width == 0 {
|
||||||
|
return "starting…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two columns (status+controls vs temps) above a wide log
|
||||||
|
leftW := (m.width - 4) / 2
|
||||||
|
rightW := m.width - 4 - leftW
|
||||||
|
if leftW < 30 {
|
||||||
|
leftW = m.width - 4
|
||||||
|
rightW = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
status := panelStyle.Width(leftW).Render(m.renderStatus())
|
||||||
|
temps := panelStyle.Width(rightW).Render(m.renderTemps())
|
||||||
|
controls := panelStyle.Width(m.width - 4).Render(m.renderControls())
|
||||||
|
|
||||||
|
top := lipgloss.JoinHorizontal(lipgloss.Top, status, temps)
|
||||||
|
|
||||||
|
header := titleStyle.Render(" printcontrol ") + dimStyle.Render(" · 3D printer TUI")
|
||||||
|
footer := m.renderFooter()
|
||||||
|
|
||||||
|
// Reserve room for header + top + controls + footer (plus panel chrome).
|
||||||
|
// Whatever rows are left go to the log; if the terminal is short we keep
|
||||||
|
// the log to two lines so the footer never falls off.
|
||||||
|
reserved := lipgloss.Height(header) +
|
||||||
|
lipgloss.Height(top) +
|
||||||
|
lipgloss.Height(controls) +
|
||||||
|
lipgloss.Height(footer)
|
||||||
|
logRows := m.height - reserved - 3 // 3 = log panel border + title
|
||||||
|
if logRows < 2 {
|
||||||
|
logRows = 2
|
||||||
|
}
|
||||||
|
if logRows > 12 {
|
||||||
|
logRows = 12
|
||||||
|
}
|
||||||
|
logBox := panelStyle.Width(m.width - 4).Render(m.renderLog(logRows))
|
||||||
|
|
||||||
|
// SD picker takes over the log slot when open — keeps the rest of the
|
||||||
|
// status panels visible while the user is choosing.
|
||||||
|
if m.focus == focusSDPicker {
|
||||||
|
logBox = panelStyle.Width(m.width - 4).Render(m.renderSDPicker(logRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, header, top, controls, logBox, footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderStatus() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("● Status") + "\n")
|
||||||
|
|
||||||
|
conn := "disconnected"
|
||||||
|
if m.state.Connected {
|
||||||
|
conn = fmt.Sprintf("%s @ %d", m.state.Port, m.state.Baud)
|
||||||
|
if m.state.Firmware != "" {
|
||||||
|
conn += dimStyle.Render(" · " + m.state.Firmware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(kv("Connection:", conn) + " " + statePill(m.state.PrintState) + "\n")
|
||||||
|
|
||||||
|
file := m.state.SDFilename
|
||||||
|
if file == "" {
|
||||||
|
file = "—"
|
||||||
|
}
|
||||||
|
b.WriteString(kv("File:", file) + "\n")
|
||||||
|
|
||||||
|
prog := int(m.state.Progress() * 100)
|
||||||
|
bar := progressBar(prog, 30)
|
||||||
|
b.WriteString(kv("Progress:", fmt.Sprintf("%s %3d%%", bar, prog)) + "\n")
|
||||||
|
|
||||||
|
etaStr := "—"
|
||||||
|
if eta, ok := m.state.ETA(); ok {
|
||||||
|
etaStr = formatDuration(eta)
|
||||||
|
}
|
||||||
|
b.WriteString(kv("ETA:", etaStr))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderTemps() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("● Temperatures") + "\n")
|
||||||
|
|
||||||
|
hot := m.state.Temps["T"]
|
||||||
|
if (hot == printer.TempPair{}) {
|
||||||
|
hot = m.state.Temps["T0"]
|
||||||
|
}
|
||||||
|
b.WriteString(kv("Hotend:", tempStr(hot)) + "\n")
|
||||||
|
|
||||||
|
bed := m.state.Temps["B"]
|
||||||
|
b.WriteString(kv("Bed:", tempStr(bed)) + "\n")
|
||||||
|
|
||||||
|
chamber, hasChamber := m.state.Temps["C"]
|
||||||
|
if hasChamber {
|
||||||
|
b.WriteString(kv("Chamber:", tempStr(chamber)) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Input lines
|
||||||
|
hotPrompt := " set hotend: "
|
||||||
|
if m.focus == focusHotendInput {
|
||||||
|
hotPrompt += valueStyle.Render(m.hotendInput + "▏")
|
||||||
|
} else {
|
||||||
|
hotPrompt += dimStyle.Render("[h]")
|
||||||
|
}
|
||||||
|
b.WriteString(hotPrompt + "\n")
|
||||||
|
|
||||||
|
bedPrompt := " set bed: "
|
||||||
|
if m.focus == focusBedInput {
|
||||||
|
bedPrompt += valueStyle.Render(m.bedInput + "▏")
|
||||||
|
} else {
|
||||||
|
bedPrompt += dimStyle.Render("[b]")
|
||||||
|
}
|
||||||
|
b.WriteString(bedPrompt)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderControls() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("● Controls") + "\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Feedrate: %s %s Flow: %s %s Fan: %s %s Babystep Z: %s %s\n",
|
||||||
|
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FeedratePct)),
|
||||||
|
dimStyle.Render("[-/+] [0]"),
|
||||||
|
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FlowPct)),
|
||||||
|
dimStyle.Render("[ [ / ] ]"),
|
||||||
|
valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FanPct)),
|
||||||
|
dimStyle.Render("[f/F]"),
|
||||||
|
valueStyle.Render(fmt.Sprintf("%+.3f", m.state.BabystepZ)),
|
||||||
|
dimStyle.Render("[{ / }] [Z]"),
|
||||||
|
))
|
||||||
|
if m.focus == focusGcodeInput {
|
||||||
|
b.WriteString(" > " + valueStyle.Render(m.gcodeInput + "▏"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(dimStyle.Render(" press [g] to send raw G-code"))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderSDPicker(rows int) string {
|
||||||
|
title := titleStyle.Render("● SD card") + dimStyle.Render(" ↑/↓ select ⏎ print esc cancel")
|
||||||
|
if len(m.sdPickerFiles) == 0 {
|
||||||
|
return title + "\n" + dimStyle.Render(" (no files reported)")
|
||||||
|
}
|
||||||
|
if rows < 1 {
|
||||||
|
rows = 1
|
||||||
|
}
|
||||||
|
// Scroll window around the selected index.
|
||||||
|
from := 0
|
||||||
|
to := len(m.sdPickerFiles)
|
||||||
|
if to > rows {
|
||||||
|
from = m.sdPickerIdx - rows/2
|
||||||
|
if from < 0 {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
if from+rows > len(m.sdPickerFiles) {
|
||||||
|
from = len(m.sdPickerFiles) - rows
|
||||||
|
}
|
||||||
|
to = from + rows
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(title)
|
||||||
|
for i := from; i < to; i++ {
|
||||||
|
f := m.sdPickerFiles[i]
|
||||||
|
label := f.Name
|
||||||
|
if f.LongName != "" {
|
||||||
|
label = f.LongName + dimStyle.Render(" ("+f.Name+")")
|
||||||
|
}
|
||||||
|
size := ""
|
||||||
|
if f.Size > 0 {
|
||||||
|
size = dimStyle.Render(" " + humanSize(f.Size))
|
||||||
|
}
|
||||||
|
row := " " + label + size
|
||||||
|
if i == m.sdPickerIdx {
|
||||||
|
row = valueStyle.Render("▸ ") + valueStyle.Render(label) + size
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + row)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanSize(n int64) string {
|
||||||
|
const k = 1024
|
||||||
|
switch {
|
||||||
|
case n < k:
|
||||||
|
return fmt.Sprintf("%dB", n)
|
||||||
|
case n < k*k:
|
||||||
|
return fmt.Sprintf("%.1fK", float64(n)/k)
|
||||||
|
case n < k*k*k:
|
||||||
|
return fmt.Sprintf("%.1fM", float64(n)/(k*k))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.1fG", float64(n)/(k*k*k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderLog(rows int) string {
|
||||||
|
title := titleStyle.Render("● Log")
|
||||||
|
if len(m.log) == 0 {
|
||||||
|
return title + "\n" + dimStyle.Render(" (no messages yet)")
|
||||||
|
}
|
||||||
|
if rows < 1 {
|
||||||
|
rows = 1
|
||||||
|
}
|
||||||
|
from := 0
|
||||||
|
if len(m.log) > rows {
|
||||||
|
from = len(m.log) - rows
|
||||||
|
}
|
||||||
|
body := strings.Join(m.log[from:], "\n")
|
||||||
|
return title + "\n" + dimStyle.Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderFooter() string {
|
||||||
|
keys := []string{
|
||||||
|
key("c") + " connect",
|
||||||
|
key("d") + " disconnect",
|
||||||
|
key("s") + " sd start",
|
||||||
|
key("p") + " pause",
|
||||||
|
key("r") + " resume",
|
||||||
|
key("x") + " cancel",
|
||||||
|
key("h") + " hotend",
|
||||||
|
key("b") + " bed",
|
||||||
|
key("g") + " gcode",
|
||||||
|
key("q") + " quit",
|
||||||
|
}
|
||||||
|
hints := keyHintStyle.Render(strings.Join(keys, " "))
|
||||||
|
if time.Now().Before(m.flashUntil) && m.flashMsg != "" {
|
||||||
|
return hints + "\n" + valueStyle.Render(m.flashMsg)
|
||||||
|
}
|
||||||
|
if m.state.ErrorMsg != "" {
|
||||||
|
return hints + "\n" + pillError.Render("⚠ "+m.state.ErrorMsg)
|
||||||
|
}
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- view helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func kv(label, value string) string {
|
||||||
|
return labelStyle.Render(fmt.Sprintf("%-12s", label)) + " " + valueStyle.Render(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func key(k string) string { return keyStyle.Render("[" + k + "]") }
|
||||||
|
|
||||||
|
func tempStr(p printer.TempPair) string {
|
||||||
|
if p.Current == 0 && p.Target == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%5.1f / %5.1f °C", p.Current, p.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statePill(s printer.PrintState) string {
|
||||||
|
switch s {
|
||||||
|
case printer.StateSDPrinting, printer.StateHostPrinting:
|
||||||
|
return pillPrint.Render(s.String())
|
||||||
|
case printer.StatePaused:
|
||||||
|
return pillPause.Render(s.String())
|
||||||
|
case printer.StateError:
|
||||||
|
return pillError.Render(s.String())
|
||||||
|
default:
|
||||||
|
return pillIdle.Render(s.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressBar(pct, width int) string {
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
filled := pct * width / 100
|
||||||
|
return "[" + strings.Repeat("█", filled) + strings.Repeat("·", width-filled) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
d = d.Round(time.Second)
|
||||||
|
h := d / time.Hour
|
||||||
|
d -= h * time.Hour
|
||||||
|
mm := d / time.Minute
|
||||||
|
d -= mm * time.Minute
|
||||||
|
s := d / time.Second
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh %02dm", h, mm)
|
||||||
|
}
|
||||||
|
if mm > 0 {
|
||||||
|
return fmt.Sprintf("%dm %02ds", mm, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
32
go/internal/tui/style.go
Normal file
32
go/internal/tui/style.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Colours are deliberately ANSI 16-colour where possible so the terminal theme
|
||||||
|
// (Tokyo Night / Catppuccin / whatever Omarchy is wearing today) drives the look.
|
||||||
|
var (
|
||||||
|
colAccent = lipgloss.AdaptiveColor{Light: "5", Dark: "13"} // magenta
|
||||||
|
colOK = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} // green
|
||||||
|
colWarn = lipgloss.AdaptiveColor{Light: "3", Dark: "11"} // yellow
|
||||||
|
colErr = lipgloss.AdaptiveColor{Light: "1", Dark: "9"} // red
|
||||||
|
colMuted = lipgloss.AdaptiveColor{Light: "8", Dark: "8"} // bright black
|
||||||
|
colInfo = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} // blue
|
||||||
|
|
||||||
|
panelStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colAccent).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
titleStyle = lipgloss.NewStyle().Foreground(colAccent).Bold(true)
|
||||||
|
labelStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||||
|
valueStyle = lipgloss.NewStyle().Bold(true)
|
||||||
|
dimStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||||
|
|
||||||
|
pillIdle = lipgloss.NewStyle().Padding(0, 1).Foreground(colMuted)
|
||||||
|
pillPrint = lipgloss.NewStyle().Padding(0, 1).Foreground(colOK).Bold(true)
|
||||||
|
pillPause = lipgloss.NewStyle().Padding(0, 1).Foreground(colWarn).Bold(true)
|
||||||
|
pillError = lipgloss.NewStyle().Padding(0, 1).Foreground(colErr).Bold(true)
|
||||||
|
|
||||||
|
keyHintStyle = lipgloss.NewStyle().Foreground(colMuted)
|
||||||
|
keyStyle = lipgloss.NewStyle().Foreground(colInfo).Bold(true)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user