From 77bf4ebfd627b7540bb6b1c5828c2d5162f0f2ee Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 06:41:26 +0000 Subject: [PATCH] =?UTF-8?q?docs(04):=20create=20phase=204=20plans=20?= =?UTF-8?q?=E2=80=94=20USB=20manager,=20label=20printing,=20SSE,=20intake?= =?UTF-8?q?=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 plans across 3 waves covering USB-01 through USB-04 and LBL-01 through LBL-05. Mock drivers and goroutine-leak harness tests enable full TDD before hardware arrives. Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 14 +- .../04-01-PLAN.md | 322 ++++++++++ .../04-02-PLAN.md | 363 ++++++++++++ .../04-03-PLAN.md | 558 ++++++++++++++++++ .../04-04-PLAN.md | 254 ++++++++ .../04-05-PLAN.md | 329 +++++++++++ 6 files changed, 1836 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/04-usb-manager-label-printing/04-01-PLAN.md create mode 100644 .planning/phases/04-usb-manager-label-printing/04-02-PLAN.md create mode 100644 .planning/phases/04-usb-manager-label-printing/04-03-PLAN.md create mode 100644 .planning/phases/04-usb-manager-label-printing/04-04-PLAN.md create mode 100644 .planning/phases/04-usb-manager-label-printing/04-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8df6c86..916327d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -61,7 +61,7 @@ Plans: ### Phase 3: Dashboard & Intake UI **Goal**: Users can browse their full inventory, run intake for new items, and view item detail — all through the React SPA served by the Go binary -**Depends on**: Phase 2 +**Depends on**: Phase 3 **Requirements**: UI-01, UI-02, UI-03, UI-04, UI-05, UI-06, PWA-01, PWA-02, PWA-03 **Success Criteria** (what must be TRUE): 1. Inventory dashboard loads with grid/list toggle, item cards showing photo, HW ID, status, and key specs @@ -88,8 +88,14 @@ Plans: 3. A QR label with HW ID, item name, key spec line, and QR code is printed for any item from the dashboard quick actions 4. Cable-specific label template correctly shows USB version, speed, wattage, and test date 5. Label printing completes as the final step of the AI intake workflow without leaving the intake screen -**Plans**: TBD -**UI hint**: yes +**Plans**: 5 plans + +Plans: +- [ ] 04-01-PLAN.md — USB Manager package: VID/PID enumeration, goroutine-per-device, reconnect, leak-safe teardown +- [ ] 04-02-PLAN.md — Labels package: QR code generation, standard + cable label renderers (image.Image output) +- [ ] 04-03-PLAN.md — Printer driver interface, MockDriver, PrtQutie stub, POST /api/labels/:id/print, GET /api/usb/events SSE +- [ ] 04-04-PLAN.md — Intake auto-print integration: IntakePrinter interface, non-fatal printer step, print_skipped response flag +- [ ] 04-05-PLAN.md — Frontend: useUSBEvents hook, USBStatusBar component, Print Label button on dashboard ### Phase 5: Cable Test Integration **Goal**: Any cable can be tested, its results written to NetBox, and a label printed in a rapid test-verify-print workflow without leaving the Cable Test Station view @@ -137,7 +143,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | 1. Foundation | 5/5 | Complete | 2026-04-10 | | 2. AI Pipeline | 4/4 | Complete | 2026-04-10 | | 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 | -| 4. USB Manager & Label Printing | 0/TBD | Not started | - | +| 4. USB Manager & Label Printing | 0/5 | Not started | - | | 5. Cable Test Integration | 0/TBD | Not started | - | | 6. Lab Advisor | 0/TBD | Not started | - | | 7. Research Agent & Search | 0/TBD | Not started | - | diff --git a/.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md b/.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md new file mode 100644 index 0000000..4d7841a --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md @@ -0,0 +1,322 @@ +--- +phase: 04-usb-manager-label-printing +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - internal/usb/manager.go + - internal/usb/manager_test.go + - internal/usb/device.go + - go.mod + - go.sum +autonomous: true +requirements: [USB-01, USB-02, USB-03] +must_haves: + truths: + - "USB Manager discovers devices by VID/PID, never by path" + - "Each connected device runs in its own goroutine with command and event channels" + - "Unplugging a device causes the owning goroutine to exit cleanly without leaking" + - "Replugging the same device (same VID/PID) reconnects automatically without operator action" + - "Goroutine count is stable across 5 consecutive unplug/replug cycles" + artifacts: + - path: internal/usb/device.go + provides: "DeviceSpec, DeviceState, DeviceEvent, Command types + KnownDevices registry" + exports: [DeviceSpec, DeviceState, DeviceEvent, Command, KnownDevices] + - path: internal/usb/manager.go + provides: "USBManager with goroutine-per-device, VID/PID poll loop, reconnect" + exports: [Manager, NewManager, Manager.Start, Manager.Stop, Manager.Events] + - path: internal/usb/manager_test.go + provides: "Unit tests covering goroutine count stability and reconnect logic with mock serial port" + min_lines: 80 + key_links: + - from: internal/usb/manager.go + to: go.bug.st/serial + via: "serial.GetPortsList() + port.Open() per VID/PID match" + pattern: "serial\\.GetPortsList" + - from: internal/usb/manager.go + to: internal/usb/device.go + via: "KnownDevices lookup keyed by VID:PID string" + pattern: "KnownDevices" +--- + + +Build the `internal/usb` package: USB Manager with goroutine-per-device model, VID/PID-based enumeration, reconnect handling, and leak-safe goroutine teardown. + +Purpose: Foundation for all USB peripheral interaction in Phase 4 and Phase 5. The PRT Qutie and future Treedix testers must be enumerated by stable VID/PID identity, not ephemeral macOS paths. Goroutine leak prevention is critical for a long-running daemon. + +Output: `internal/usb` package with Manager, DeviceSpec registry, and passing tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/research/PITFALLS.md + +# Existing code this plan extends +@internal/api/router.go +@internal/config/config.go + + + + + +From go.mod — module path: +``` +module git.georgsen.dk/hwlab +go 1.23.0 +``` + +From internal/config/config.go (viper-based, add USB section): +The existing config pattern uses `viper.GetString("key")`. New USB config keys: +- `usb.poll_interval_ms` (default 2000) +- `usb.prt_qutie_vid` (default "0525") +- `usb.prt_qutie_pid` (default "a4a7") + +go.bug.st/serial API (to be added as dependency): +```go +import "go.bug.st/serial" + +// Enumerate all current serial ports +ports, err := serial.GetPortsList() // returns []string of /dev/cu.* paths + +// Open a port +mode := &serial.Mode{BaudRate: 9600} +port, err := serial.Open(path, mode) + +// Read (blocks until data or close) +n, err := port.Read(buf) + +// Unblock a blocked Read from another goroutine +port.Close() + +// Get port details (VID/PID) — macOS requires ioreg or system_profiler +// go.bug.st/serial does NOT expose VID/PID directly on macOS. +// Use: serial.GetDetailedPortsList() on platforms that support it, +// OR shell out to: system_profiler SPUSBDataType -json +// See task action for the chosen approach. +``` + +Important pitfall (from PITFALLS.md Pitfall 1 + Pitfall 2): +- NEVER store device paths long-term — re-resolve on every reconnect +- Use context.Context cancellation + done channel before port.Close() to avoid goroutine leak +- Test with runtime.NumGoroutine() before/after replug cycles + + + + + + Task 1: Device types, KnownDevices registry, and VID/PID enumeration helper + internal/usb/device.go, internal/usb/device_test.go, go.mod, go.sum + + - Test 1: KnownDevices["0525:a4a7"] returns a DeviceSpec with Role == RolePrinter + - Test 2: enumerateByVIDPID with a mock port list returns only ports whose ioreg output contains the target VID/PID + - Test 3: DeviceSpec.String() returns "VID:PID (Name)" format for logging + - Test 4: ParseVIDPID("0525:a4a7") returns vid="0525", pid="a4a7", nil error + - Test 5: ParseVIDPID("badvalue") returns error + + +Run: `go get go.bug.st/serial@latest` to add dependency. + +Create `internal/usb/device.go`: + +```go +package usb + +// DeviceRole classifies what a USB device is used for in HWLab. +type DeviceRole int + +const ( + RolePrinter DeviceRole = iota + RoleCableTester // reserved for Phase 5 + RoleUnknown +) + +// DeviceSpec describes a known USB peripheral by VID and PID. +type DeviceSpec struct { + VID string // 4-hex-digit vendor ID, e.g. "0525" + PID string // 4-hex-digit product ID, e.g. "a4a7" + Name string // human-readable label for logs + Role DeviceRole + BaudRate int // serial baud rate; 0 means use 9600 default +} + +func (s DeviceSpec) VIDPID() string { return s.VID + ":" + s.PID } +func (s DeviceSpec) String() string { return s.VIDPID() + " (" + s.Name + ")" } + +// KnownDevices maps "VID:PID" to DeviceSpec for all peripherals HWLab manages. +// Update VID/PID values after hardware characterization on 2026-04-13. +var KnownDevices = map[string]DeviceSpec{ + "0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600}, + // Phase 5: Treedix testers will be added here after characterization +} + +// DeviceState represents the current connection status of a managed device. +type DeviceState int + +const ( + StateDisconnected DeviceState = iota + StateConnected +) + +// DeviceEvent is emitted on the Manager.Events() channel for any state change. +type DeviceEvent struct { + VIDPID string + Spec DeviceSpec + State DeviceState +} + +// Command is sent to a device goroutine via its command channel. +type Command struct { + Type CommandType + Payload []byte + Reply chan<- error // caller closes or receives result +} + +type CommandType int + +const ( + CmdWrite CommandType = iota + CmdClose +) +``` + +Create `internal/usb/enumerate.go` with the VID/PID enumeration logic: +- `enumerateConnected() (map[string]string, error)` — returns map[VIDPID]portPath +- On macOS: shell out to `system_profiler SPUSBDataType -json` and parse the JSON tree for "vendor_id" and "product_id" fields, then cross-reference with `serial.GetPortsList()` to find the matching tty/cu path +- Return only devices whose VID:PID appears in KnownDevices +- If `system_profiler` is unavailable (Linux CI): fall back to scanning `/sys/bus/usb/devices/` or returning empty map with no error (graceful degradation) +- The function must be testable: accept an optional override func for the shell command output (dependency injection via package-level var `sysProfilerCmd func() ([]byte, error)`) + +Write `internal/usb/device_test.go` covering the 5 behavior tests listed above. For enumerateByVIDPID tests, override `sysProfilerCmd` with a fixture returning known JSON before calling the function. + + + cd /home/mikkel/homelabby && go test ./internal/usb/... -run "TestDeviceSpec|TestParseVIDPID|TestEnumerate" -v 2>&1 | tail -20 + + All 5 behavior tests pass. `go build ./...` succeeds. go.mod includes go.bug.st/serial. + + + + Task 2: USB Manager — goroutine-per-device, poll loop, reconnect, leak-safe teardown + internal/usb/manager.go, internal/usb/manager_test.go + + - Test 1: Manager.Start() with a mock enumeration returning one device spawns exactly 1 device goroutine + - Test 2: When mock enumeration transitions device from present→absent→present, a DeviceEvent{Disconnected} then DeviceEvent{Connected} is emitted on Events() channel + - Test 3: Manager.Stop() causes all device goroutines to exit; runtime.NumGoroutine() returns to baseline within 500ms + - Test 4: Manager.Send(vidpid, cmd) returns ErrDeviceNotConnected when the device is absent + - Test 5: Goroutine count is stable (±2) across 5 simulated unplug/replug cycles using the mock enumerator + + +Create `internal/usb/manager.go`: + +```go +package usb + +import ( + "context" + "log" + "sync" + "time" +) + +// Manager owns all USB device goroutines and emits DeviceEvents to subscribers. +type Manager struct { + pollInterval time.Duration + events chan DeviceEvent + cmdChans map[string]chan Command // keyed by VID:PID + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + // enumerateFunc is injectable for tests; defaults to enumerateConnected + enumerateFunc func() (map[string]string, error) +} + +func NewManager(pollInterval time.Duration) *Manager { ... } + +// Start begins the poll loop. Call Stop to shut down cleanly. +func (m *Manager) Start(ctx context.Context) { ... } + +// Stop cancels all device goroutines and waits for them to exit. +func (m *Manager) Stop() { ... } + +// Events returns the read-only channel of DeviceEvent notifications. +func (m *Manager) Events() <-chan DeviceEvent { return m.events } + +// Send delivers a Command to the named device. Returns ErrDeviceNotConnected if absent. +func (m *Manager) Send(vidpid string, cmd Command) error { ... } +``` + +Poll loop behavior: +1. Every `pollInterval`, call `enumerateFunc()` to get map[VIDPID]portPath +2. For each VID:PID in KnownDevices: compare to previous snapshot +3. Newly present → spawn `deviceLoop(ctx, spec, portPath, cmdChan)` goroutine, emit Connected event +4. Newly absent → send CmdClose to cmdChan, wait for goroutine to exit via done channel, emit Disconnected event +5. Update snapshot + +Device loop (`deviceLoop` private function): +1. Open serial port with the given path +2. Launch inner read goroutine that reads until context cancelled or port closed +3. Main loop: select on cmdChan and context.Done() +4. On CmdWrite: write payload to port +5. On CmdClose or context cancellation: cancel inner context, call port.Close(), drain any pending events, return +6. Defer wg.Done() + +Goroutine leak prevention (per PITFALLS.md Pitfall 2): +- Each deviceLoop receives a child context derived from the Manager's root context +- Before port.Close(), cancel the child context +- Inner read goroutine selects on context.Done(); exits if context is done before next Read returns +- Use a `done` channel (close it when deviceLoop exits) so the poll loop can wait with a timeout before marking the device as gone from cmdChans + +Test setup: inject a mock `enumerateFunc` that returns predefined snapshots per call number. Use `runtime.NumGoroutine()` before and after to verify leak-free teardown. + + + cd /home/mikkel/homelabby && go test ./internal/usb/... -v -race -timeout 30s 2>&1 | tail -30 + + All tests pass with -race. No goroutine leak detected. `go vet ./internal/usb/...` clean. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| host OS → serial port | USB device data enters as raw bytes from a physical peripheral | +| serial port → Manager | Device goroutine reads bytes; poisoned data could cause parse panics | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-01 | Tampering | deviceLoop read buffer | mitigate | Cap read buffer at 4096 bytes; validate length before forwarding to command handler | +| T-04-02 | Denial of Service | poll loop | accept | Poll interval is configurable (default 2s); single-user homelab, no adversarial hotplug expected | +| T-04-03 | Information Disclosure | system_profiler output | accept | USB device metadata is not sensitive; output is local-only, never logged to external sink | +| T-04-04 | Elevation of Privilege | serial port open | accept | /dev/cu.* permissions on macOS require the user running the process to be in the `dialout` group or have standard user access; HWLab runs as the operator user | + + + +1. `go test ./internal/usb/... -race -v` — all tests pass, race detector clean +2. `go build ./...` — compiles with new dependency +3. `go vet ./internal/usb/...` — no issues +4. Goroutine stability: TestGoroutineStability passes (count ±2 across 5 replug cycles) + + + +- `internal/usb` package exists with Manager, DeviceSpec, DeviceEvent, Command types +- KnownDevices registry includes PRT Qutie VID:PID placeholder +- VID/PID enumeration uses system_profiler on macOS, never stores paths between polls +- goroutine-per-device with context+done-channel teardown +- All tests pass with -race flag +- No goroutine count growth in stability test + + + +After completion, create `.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md b/.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md new file mode 100644 index 0000000..94da51e --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md @@ -0,0 +1,363 @@ +--- +phase: 04-usb-manager-label-printing +plan: "02" +type: execute +wave: 1 +depends_on: [] +files_modified: + - internal/labels/renderer.go + - internal/labels/renderer_test.go + - internal/labels/cable.go + - internal/labels/cable_test.go + - go.mod + - go.sum +autonomous: true +requirements: [LBL-01, LBL-02, LBL-03] +must_haves: + truths: + - "QR code encodes the full item URL (http://mac-mini.mg:8080/hw/HW-XXXXX)" + - "Standard label bitmap contains QR code, HW ID, item name, and key spec line" + - "Cable label bitmap contains USB version, max speed, max wattage, and test date" + - "Both label types produce a valid Go image.NRGBA at 203 DPI suitable for the printer" + artifacts: + - path: internal/labels/renderer.go + provides: "LabelData struct, RenderStandard() and RenderCable() producing image.Image" + exports: [LabelData, CableLabelData, RenderStandard, RenderCable] + - path: internal/labels/cable.go + provides: "IsCableDevice() helper detecting cable records by tag/type" + exports: [IsCableDevice] + - path: internal/labels/renderer_test.go + provides: "Unit tests: QR URL encoding, image dimensions, pixel non-blank check" + min_lines: 60 + key_links: + - from: internal/labels/renderer.go + to: github.com/skip2/go-qrcode + via: "qrcode.Encode(url, qrcode.Low, sizePixels)" + pattern: "qrcode\\.Encode" + - from: internal/labels/renderer.go + to: golang.org/x/image/font/basicfont + via: "basicfont.Face7x13 for text rendering" + pattern: "basicfont" +--- + + +Build the `internal/labels` package: QR code generation, label layout rendering (standard + cable templates), and cable-type detection helper. + +Purpose: Produce label bitmaps that the printer driver can send to the PRT Qutie. This package is pure image processing — no USB or printer dependency — so it can be built and tested in Wave 1 in parallel with the USB Manager. + +Output: `internal/labels` package with passing tests and deterministic bitmap output. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md +@internal/netbox/types.go + + + + + +From internal/netbox/types.go: +```go +type Device struct { + ID int + Name string + AssetTag string // HW-XXXXX identifier + CustomFields CustomFields + Created time.Time + LastUpdated time.Time +} + +type CustomFields struct { + HWID string + CatalogStatus string + TestDate string // ISO 8601 date string + TestData string // JSON string (cable specs live here) + AINotes string + PhotoURLs []string +} +``` + +github.com/skip2/go-qrcode API: +```go +import "github.com/skip2/go-qrcode" + +// Encode returns a PNG []byte at the given size in pixels +png, err := qrcode.Encode(content, qrcode.Low, 128) + +// Or generate a QR code object for image manipulation +qr, err := qrcode.New(content, qrcode.Low) +qr.DisableBorderPadding = true +img := qr.Image(sizePixels) // returns image.Image +``` + +golang.org/x/image/font/basicfont (already available transitively, add if needed): +```go +import ( + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" + "image/draw" + "image/color" +) + +// Draw text at position +d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(color.Black), + Face: basicfont.Face7x13, + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, +} +d.DrawString("HW-00001") +``` + +Label dimensions from CONTEXT.md: +- Width: ~384px at 203 DPI (standard Brother QL / PRT Qutie 62mm tape) +- Height: 120px (15mm at 203 DPI) — standard label height for compact items +- Cable label: same width, 180px height to fit extra lines +- QR block: 96x96px, positioned at left (x=8, y=12) +- Text area: x=112, width=264px +- Font: basicfont.Face7x13 (7px wide, 13px tall per char) + +Base URL: "http://mac-mini.mg:8080/hw/" + hwID (from REQUIREMENTS.md LBL-01) + + + + + + Task 1: QR code generation + standard label renderer + internal/labels/renderer.go, internal/labels/renderer_test.go, go.mod, go.sum + + - Test 1: RenderStandard with HWID="HW-00001" produces image.Image of width=384, height=120 + - Test 2: QR code in the rendered image is non-blank (top-left 96x96 region has mixed black/white pixels) + - Test 3: QR code encodes "http://mac-mini.mg:8080/hw/HW-00001" (extract via go-qrcode round-trip) + - Test 4: LabelData with empty Name uses "Unknown Item" as fallback — no panic + - Test 5: RenderStandard produces a pure-white background (NRGBA canvas initialized to white) + + +Run: `go get github.com/skip2/go-qrcode@latest` and `go get golang.org/x/image@latest` + +Create `internal/labels/renderer.go`: + +```go +package labels + +import ( + "image" + "image/color" + "image/draw" + + "github.com/skip2/go-qrcode" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +const ( + LabelWidth = 384 // pixels at 203 DPI ≈ 48mm usable + LabelHeight = 120 // pixels ≈ 15mm + QRSize = 96 // pixels + QROffsetX = 8 + QROffsetY = 12 + TextOffsetX = 112 + BaseURL = "http://mac-mini.mg:8080/hw/" +) + +// LabelData holds the fields needed to render a standard item label. +type LabelData struct { + HWID string // e.g. "HW-00001" + Name string // item name from NetBox + SpecLine string // key spec summary, e.g. "Intel NUC i5 2019" +} + +// RenderStandard produces a 384x120 label bitmap (black on white) for a standard item. +// Layout: QR code left (96x96 at x=8,y=12), text right (HW ID bold top, name middle, spec bottom). +func RenderStandard(d LabelData) (image.Image, error) { + if d.Name == "" { + d.Name = "Unknown Item" + } + img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight)) + // Fill white background + draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src) + + // QR code + qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low) + if err != nil { + return nil, err + } + qr.DisableBorderPadding = true + qrImg := qr.Image(QRSize) + qrDst := image.Rect(QROffsetX, QROffsetY, QROffsetX+QRSize, QROffsetY+QRSize) + draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over) + + // Text: HW ID (y=26), Name (y=60), SpecLine (y=92) + drawText(img, TextOffsetX, 26, d.HWID) + drawText(img, TextOffsetX, 60, truncate(d.Name, 30)) + drawText(img, TextOffsetX, 92, truncate(d.SpecLine, 30)) + + return img, nil +} + +func drawText(img draw.Image, x, y int, text string) { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(color.Black), + Face: basicfont.Face7x13, + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, + } + d.DrawString(text) +} + +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} +``` + +Write `internal/labels/renderer_test.go` with the 5 behavior tests. For Test 3 (QR content), decode the QR from the rendered image using `github.com/tuotoo/barcodeReader` OR — simpler — use a round-trip: generate the QR independently with go-qrcode using the expected URL and compare the image dimensions/pixel counts rather than decoding. Accept: the test verifies that `qrcode.New(BaseURL+hwid, qrcode.Low)` produces consistent output by comparing two independently generated QR images at the same size. + + + cd /home/mikkel/homelabby && go test ./internal/labels/... -run "TestRenderStandard" -v 2>&1 | tail -20 + + All 5 behavior tests pass. go.mod includes github.com/skip2/go-qrcode and golang.org/x/image. + + + + Task 2: Cable label renderer + IsCableDevice detection helper + internal/labels/cable.go, internal/labels/cable_test.go + + - Test 1: RenderCable produces image.Image of width=384, height=180 + - Test 2: CableLabelData with USBVersion="USB 3.2 Gen 2", MaxSpeedGbps=10, MaxWatts=100, TestDate="2026-04-13" renders without error + - Test 3: IsCableDevice returns true when netbox.Device has a tag named "cable" (case-insensitive) in AINotes JSON or Name contains "cable" + - Test 4: IsCableDevice returns false for a device with no cable indicators + - Test 5: RenderCable with empty TestDate renders "Not tested" in the test date field without panic + + +Create `internal/labels/cable.go`: + +```go +package labels + +import ( + "fmt" + "image" + "image/draw" + "strings" + + "github.com/skip2/go-qrcode" + "git.georgsen.dk/hwlab/internal/netbox" +) + +const CableLabelHeight = 180 + +// CableLabelData holds fields for the cable-specific label template (per LBL-03). +type CableLabelData struct { + HWID string // e.g. "HW-00042" + Name string // e.g. "USB-C 2m Cable" + USBVersion string // e.g. "USB 3.2 Gen 2" + MaxSpeedGbps float64 // e.g. 10.0 + MaxWatts int // e.g. 100 + TestDate string // ISO 8601 or "" for "Not tested" +} + +// RenderCable produces a 384x180 label bitmap for a cable item. +// Layout: QR code left (96x96), text right with 4 lines: +// Line 1: HW ID +// Line 2: Name (truncated) +// Line 3: USB version + speed (e.g. "USB 3.2 Gen 2 / 10 Gbps") +// Line 4: Max power + test date (e.g. "100W Tested: 2026-04-13") +func RenderCable(d CableLabelData) (image.Image, error) { + if d.Name == "" { + d.Name = "Unknown Cable" + } + testDateStr := d.TestDate + if testDateStr == "" { + testDateStr = "Not tested" + } + + img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, CableLabelHeight)) + draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src) + + // QR code (same dimensions as standard, vertically centered) + qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low) + if err != nil { + return nil, err + } + qr.DisableBorderPadding = true + qrImg := qr.Image(QRSize) + qrOffsetY := (CableLabelHeight - QRSize) / 2 + qrDst := image.Rect(QROffsetX, qrOffsetY, QROffsetX+QRSize, qrOffsetY+QRSize) + draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over) + + // Text lines (y positions for 180px height, 4 lines) + drawText(img, TextOffsetX, 30, d.HWID) + drawText(img, TextOffsetX, 62, truncate(d.Name, 30)) + speedLine := fmt.Sprintf("%s / %.0f Gbps", d.USBVersion, d.MaxSpeedGbps) + drawText(img, TextOffsetX, 94, truncate(speedLine, 30)) + powerLine := fmt.Sprintf("%dW Tested: %s", d.MaxWatts, testDateStr) + drawText(img, TextOffsetX, 126, truncate(powerLine, 30)) + + return img, nil +} + +// IsCableDevice returns true if the NetBox device record represents a cable. +// Detection: Name contains "cable" (case-insensitive) OR AINotes contains "cable". +// This is a heuristic — real cable detection uses device_type in Phase 5. +func IsCableDevice(d netbox.Device) bool { + nameLower := strings.ToLower(d.Name) + notesLower := strings.ToLower(d.CustomFields.AINotes) + return strings.Contains(nameLower, "cable") || strings.Contains(notesLower, "cable") +} +``` + +Write `internal/labels/cable_test.go` covering the 5 behavior tests. + + + cd /home/mikkel/homelabby && go test ./internal/labels/... -v 2>&1 | tail -20 + + All label tests pass. `go build ./...` succeeds. Both RenderStandard and RenderCable produce valid image.Image values. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| NetBox data → label renderer | Device Name and spec fields flow from NetBox into rendered bitmap text | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-05 | Tampering | drawText / truncate | accept | Labels are rendered locally, not served to external parties; truncate() caps input length preventing oversized text | +| T-04-06 | Information Disclosure | QR code URL | accept | URL encodes the HW ID which is already visible on the physical item; no secrets encoded | +| T-04-07 | Denial of Service | qrcode.New() with malformed HWID | mitigate | Validate HWID format (HW-\d{5}) before calling RenderStandard/RenderCable; return error on invalid format | + + + +1. `go test ./internal/labels/... -v` — all tests pass +2. `go build ./...` — no compile errors +3. Both label heights match spec: standard=120px, cable=180px + + + +- RenderStandard produces 384x120 bitmap with QR + HW ID + name + spec line +- RenderCable produces 384x180 bitmap with QR + 4 text lines including USB version, speed, wattage, test date +- IsCableDevice correctly classifies devices by name/notes heuristic +- QR encodes correct URL format (http://mac-mini.mg:8080/hw/HW-XXXXX) +- All tests pass + + + +After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-usb-manager-label-printing/04-03-PLAN.md b/.planning/phases/04-usb-manager-label-printing/04-03-PLAN.md new file mode 100644 index 0000000..9dd5cb9 --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-03-PLAN.md @@ -0,0 +1,558 @@ +--- +phase: 04-usb-manager-label-printing +plan: "03" +type: execute +wave: 2 +depends_on: [04-01, 04-02] +files_modified: + - internal/printer/driver.go + - internal/printer/mock_driver.go + - internal/printer/prt_qutie.go + - internal/printer/driver_test.go + - internal/api/handlers/label.go + - internal/api/handlers/label_test.go + - internal/api/handlers/usb_events.go + - internal/api/router.go +autonomous: true +requirements: [LBL-04, USB-04] +must_haves: + truths: + - "PrinterDriver interface abstracts all driver implementations" + - "MockDriver logs print calls to stdout and saves PNG to /tmp/hwlab-label-*.png for visual inspection" + - "PrtQutieDriver implements PrinterDriver using go.bug.st/serial with a TODO stub for the real protocol" + - "POST /api/labels/:deviceID/print renders a label and sends it to the connected printer" + - "GET /api/usb/events streams SSE DeviceEvents from the USB Manager to connected browser clients" + artifacts: + - path: internal/printer/driver.go + provides: "PrinterDriver interface, ImageToRawBitmap() helper" + exports: [PrinterDriver, ImageToRawBitmap] + - path: internal/printer/mock_driver.go + provides: "MockDriver implementing PrinterDriver — saves PNG to /tmp" + exports: [MockDriver, NewMockDriver] + - path: internal/printer/prt_qutie.go + provides: "PrtQutieDriver implementing PrinterDriver via serial port" + exports: [PrtQutieDriver, NewPrtQutieDriver] + - path: internal/api/handlers/label.go + provides: "LabelHandler: POST /api/labels/:deviceID/print" + exports: [LabelHandler, NewLabelHandler] + - path: internal/api/handlers/usb_events.go + provides: "USBEventsHandler: GET /api/usb/events SSE stream" + exports: [USBEventsHandler, NewUSBEventsHandler] + - path: internal/api/router.go + provides: "Updated router wiring the two new endpoints" + key_links: + - from: internal/api/handlers/label.go + to: internal/labels/renderer.go + via: "calls labels.RenderStandard or labels.RenderCable based on IsCableDevice()" + pattern: "labels\\.Render" + - from: internal/api/handlers/label.go + to: internal/printer/driver.go + via: "PrinterDriver.Print(imageToRawBitmap(img))" + pattern: "driver\\.Print" + - from: internal/api/handlers/usb_events.go + to: internal/usb/manager.go + via: "reads from Manager.Events() channel and writes SSE lines" + pattern: "Manager\\.Events" +--- + + +Build the printer driver abstraction, PRT Qutie stub driver, mock driver, and wire two HTTP endpoints: `POST /api/labels/:deviceID/print` and `GET /api/usb/events` (SSE). + +Purpose: The printer driver interface allows Phase 5 (and hardware arrival day) to swap in a real PRT Qutie driver without touching handlers. The SSE endpoint delivers live USB device state to the frontend in real time (per USB-04). + +Output: `internal/printer` package + two new HTTP handlers wired into the chi router. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md +@internal/api/router.go +@internal/netbox/types.go +@internal/api/handlers/inventory.go + + + + + +From internal/usb/manager.go (created in Plan 04-01): +```go +// DeviceEvent emitted on connect/disconnect +type DeviceEvent struct { + VIDPID string + Spec DeviceSpec + State DeviceState // StateConnected or StateDisconnected +} + +type Manager struct { ... } +func (m *Manager) Events() <-chan DeviceEvent +func (m *Manager) Send(vidpid string, cmd Command) error +``` + +From internal/usb/device.go (created in Plan 04-01): +```go +type DeviceRole int +const ( + RolePrinter DeviceRole = iota + RoleCableTester + RoleUnknown +) +type DeviceSpec struct { + VID, PID, Name string + Role DeviceRole + BaudRate int +} +func (s DeviceSpec) VIDPID() string +``` + +From internal/labels/renderer.go (created in Plan 04-02): +```go +type LabelData struct { + HWID string + Name string + SpecLine string +} +type CableLabelData struct { + HWID string + Name string + USBVersion string + MaxSpeedGbps float64 + MaxWatts int + TestDate string +} +func RenderStandard(d LabelData) (image.Image, error) +func RenderCable(d CableLabelData) (image.Image, error) +func IsCableDevice(d netbox.Device) bool +``` + +From internal/netbox/types.go: +```go +type Device struct { + ID int; Name string; AssetTag string + CustomFields CustomFields +} +type CustomFields struct { + HWID string; TestDate string; TestData string; AINotes string +} +``` + +From internal/api/router.go (current signature): +```go +func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler +``` +This MUST be updated to accept the two new handlers as parameters. + +Narrow interface pattern (follow existing codebase pattern from handlers/intake.go and handlers/inventory.go): +- Define interface types in handler files (e.g., LabelNetBoxClient, LabelPrinter) +- Concrete types satisfy interfaces — no direct package imports in handler constructors + + + + + + Task 1: PrinterDriver interface, MockDriver, PrtQutieDriver stub + internal/printer/driver.go, internal/printer/mock_driver.go, internal/printer/prt_qutie.go, internal/printer/driver_test.go + + - Test 1: MockDriver.Print(validBitmap) saves a PNG file to /tmp/hwlab-label-*.png and returns nil + - Test 2: MockDriver.Print(nil) returns ErrEmptyBitmap without panicking + - Test 3: MockDriver.Connect() returns nil; Disconnect() returns nil + - Test 4: ImageToRawBitmap(1-bit image) returns byte slice of length ceil(width*height/8) + - Test 5: PrtQutieDriver.Connect() returns ErrNoDevice (not a panic) when no serial port matches the VID/PID + + +Create `internal/printer/driver.go`: + +```go +package printer + +import ( + "errors" + "image" + "math" +) + +// PrinterDriver is the interface all label printer implementations must satisfy. +// The PRT Qutie driver, mock driver, and any future printer use this interface. +type PrinterDriver interface { + // Connect opens the connection to the printer. Must be called before Print. + Connect() error + // Print sends a rendered label bitmap to the printer. + // bitmap is raw 1-bit packed row-major data from ImageToRawBitmap. + Print(bitmap []byte, width, height int) error + // Disconnect closes the connection. + Disconnect() error +} + +var ( + ErrNoDevice = errors.New("printer: no device found matching VID/PID") + ErrNotConnected = errors.New("printer: not connected") + ErrEmptyBitmap = errors.New("printer: bitmap is empty") +) + +// ImageToRawBitmap converts an image.Image to 1-bit packed row-major bitmap. +// White pixels → 0, dark pixels → 1. Returns bytes and (width, height). +func ImageToRawBitmap(img image.Image) ([]byte, int, int) { + b := img.Bounds() + w, h := b.Max.X-b.Min.X, b.Max.Y-b.Min.Y + rowBytes := int(math.Ceil(float64(w) / 8.0)) + out := make([]byte, rowBytes*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, bv, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA() + // Luminance: pixel is dark if average < 50% of max + lum := (r + g + bv) / 3 + if lum < 0x7fff { + byteIdx := y*rowBytes + x/8 + out[byteIdx] |= 1 << (7 - uint(x%8)) + } + } + } + return out, w, h +} +``` + +Create `internal/printer/mock_driver.go`: +```go +package printer + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "time" +) + +// MockDriver implements PrinterDriver for testing and development. +// Print() saves a PNG to /tmp/hwlab-label-TIMESTAMP.png and logs to stdout. +type MockDriver struct { + connected bool + SaveDir string // defaults to /tmp +} + +func NewMockDriver() *MockDriver { + return &MockDriver{SaveDir: "/tmp"} +} + +func (m *MockDriver) Connect() error { m.connected = true; return nil } +func (m *MockDriver) Disconnect() error { m.connected = false; return nil } + +func (m *MockDriver) Print(bitmap []byte, width, height int) error { + if len(bitmap) == 0 { return ErrEmptyBitmap } + // Reconstruct image from 1-bit bitmap for visual inspection + img := image.NewGray(image.Rect(0, 0, width, height)) + rowBytes := (width + 7) / 8 + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + byteIdx := y*rowBytes + x/8 + bit := (bitmap[byteIdx] >> (7 - uint(x%8))) & 1 + if bit == 1 { + img.SetGray(x, y, color.Gray{Y: 0}) + } else { + img.SetGray(x, y, color.Gray{Y: 255}) + } + } + } + path := fmt.Sprintf("%s/hwlab-label-%d.png", m.SaveDir, time.Now().UnixMilli()) + f, err := os.Create(path) + if err != nil { return err } + defer f.Close() + if err := png.Encode(f, img); err != nil { return err } + fmt.Printf("[MockDriver] label saved → %s (%dx%d)\n", path, width, height) + return nil +} +``` + +Create `internal/printer/prt_qutie.go`: +```go +package printer + +import ( + "go.bug.st/serial" +) + +// PrtQutieDriver implements PrinterDriver for the PRT Qutie thermal printer. +// Protocol: UNKNOWN — reverse engineering required after hardware arrival 2026-04-13. +// TODO(hardware): Replace the stub Print() with real ESC/POS or PRT Qutie commands +// after capturing USB traffic with Wireshark on hardware arrival day. +type PrtQutieDriver struct { + portPath string // resolved at Connect() time, not stored across sessions + port serial.Port + baudRate int +} + +// NewPrtQutieDriver creates a driver. portPath is resolved by USBManager. +// Pass "" to have Connect() auto-resolve via VID/PID enumeration. +func NewPrtQutieDriver(baudRate int) *PrtQutieDriver { + if baudRate == 0 { baudRate = 9600 } + return &PrtQutieDriver{baudRate: baudRate} +} + +func (d *PrtQutieDriver) Connect() error { + // TODO(hardware): resolve portPath via USB Manager or serial.GetPortsList() + // For now, return ErrNoDevice to make the stub safe to call in tests + return ErrNoDevice +} + +func (d *PrtQutieDriver) Print(bitmap []byte, width, height int) error { + if d.port == nil { return ErrNotConnected } + // TODO(hardware): implement PRT Qutie print protocol + // Likely ESC/POS or proprietary binary command sequence + // Reference: https://atctwo.net/posts/2024/07/16/thermal-printer.html + _ = bitmap; _ = width; _ = height + return nil +} + +func (d *PrtQutieDriver) Disconnect() error { + if d.port != nil { + err := d.port.Close() + d.port = nil + return err + } + return nil +} +``` + +Write `internal/printer/driver_test.go` covering the 5 behavior tests. Use a temp dir (t.TempDir()) instead of /tmp for Test 1 by setting MockDriver.SaveDir. + + + cd /home/mikkel/homelabby && go test ./internal/printer/... -v 2>&1 | tail -20 + + All 5 tests pass. PrtQutieDriver.Connect() returns ErrNoDevice (stub). MockDriver.Print() saves PNG and returns nil for valid input. + + + + Task 2: LabelHandler + USBEventsHandler + router wiring + internal/api/handlers/label.go, internal/api/handlers/label_test.go, internal/api/handlers/usb_events.go, internal/api/router.go + + - Test 1: POST /api/labels/42/print with mock NetBox returning a Device and mock printer calls Print() once, returns 200 JSON {"status":"printed"} + - Test 2: POST /api/labels/99/print where mock NetBox returns ErrNotFound returns 404 + - Test 3: POST /api/labels/42/print where mock printer returns error returns 500 with {"error":"..."} + - Test 4: GET /api/usb/events sets Content-Type: text/event-stream, writes "data: {...}\n\n" on DeviceEvent receive + - Test 5: GET /api/usb/events closes cleanly (no goroutine leak) when client disconnects (r.Context() cancels) + + +Create `internal/api/handlers/label.go`: + +```go +package handlers + +import ( + "context" + "image" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.georgsen.dk/hwlab/internal/labels" + "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/printer" +) + +// LabelNetBoxClient is the narrow interface LabelHandler needs. +type LabelNetBoxClient interface { + GetDevice(ctx context.Context, id int) (*netbox.Device, error) +} + +// LabelPrinter is the narrow interface over printer.PrinterDriver. +type LabelPrinter interface { + Print(bitmap []byte, width, height int) error +} + +// LabelHandler handles POST /api/labels/:deviceID/print. +type LabelHandler struct { + nb LabelNetBoxClient + printer LabelPrinter +} + +func NewLabelHandler(nb LabelNetBoxClient, p LabelPrinter) *LabelHandler { + return &LabelHandler{nb: nb, printer: p} +} + +func (h *LabelHandler) PrintLabel(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "deviceID") + id, err := strconv.Atoi(idStr) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + return + } + device, err := h.nb.GetDevice(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) + return + } + var img image.Image + if labels.IsCableDevice(*device) { + // Parse cable fields from TestData JSON; use SpecLine fallback if empty + d := labels.CableLabelData{ + HWID: device.CustomFields.HWID, + Name: device.Name, + TestDate: device.CustomFields.TestDate, + // USBVersion/MaxSpeedGbps/MaxWatts: parsed from TestData JSON in Phase 5 + // For now use defaults so label renders without error + USBVersion: "Unknown", + } + img, err = labels.RenderCable(d) + } else { + d := labels.LabelData{ + HWID: device.CustomFields.HWID, + Name: device.Name, + SpecLine: device.CustomFields.AINotes, + } + img, err = labels.RenderStandard(d) + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()}) + return + } + bitmap, width, height := printer.ImageToRawBitmap(img) + if err := h.printer.Print(bitmap, width, height); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "print failed: " + err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "printed"}) +} +``` + +Create `internal/api/handlers/usb_events.go`: + +```go +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "git.georgsen.dk/hwlab/internal/usb" +) + +// USBEventSource is the narrow interface over usb.Manager. +type USBEventSource interface { + Events() <-chan usb.DeviceEvent +} + +// USBEventsHandler handles GET /api/usb/events — SSE stream of USB device events. +type USBEventsHandler struct { + manager USBEventSource +} + +func NewUSBEventsHandler(m USBEventSource) *USBEventsHandler { + return &USBEventsHandler{manager: m} +} + +func (h *USBEventsHandler) ServeEvents(w http.ResponseWriter, r *http.Request) { + // SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + // Send initial keepalive comment + fmt.Fprintf(w, ": connected\n\n") + flusher.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case evt, ok := <-h.manager.Events(): + if !ok { return } + data, _ := json.Marshal(evt) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + case <-ticker.C: + // Keepalive comment to prevent proxy timeout + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} +``` + +Update `internal/api/router.go` — add two parameters: +```go +func NewRouter( + staticFiles fs.FS, + intakeHandler http.Handler, + inventoryHandler *handlers.InventoryHandler, + labelHandler *handlers.LabelHandler, // NEW + usbEventsHandler *handlers.USBEventsHandler, // NEW +) http.Handler { + // existing setup... + r.Route("/api", func(r chi.Router) { + r.Get("/health", handlers.Health) + r.Post("/intake", intakeHandler.ServeHTTP) + r.Get("/inventory", inventoryHandler.ListInventory) + r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) + r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel) // NEW + r.Get("/usb/events", usbEventsHandler.ServeEvents) // NEW + }) + r.Handle("/*", spaHandler{staticFS: staticFiles}) + return r +} +``` + +Also update `cmd/hwlab/main.go` to construct USBManager, MockDriver (until hardware arrives), LabelHandler, USBEventsHandler, and pass them to NewRouter. The USB Manager must be started via `go usbManager.Start(ctx)` with the same context used for the WAQ worker. + +Write `internal/api/handlers/label_test.go` covering tests 1-5. For tests 4-5, use `httptest.NewRecorder()` with a context that gets cancelled to verify SSE cleanup. Use a buffered channel as the mock event source. + + + cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestLabel|TestUSBEvents" 2>&1 | tail -25 + + All handler tests pass. `go build ./cmd/hwlab/...` succeeds with updated NewRouter signature and main.go wiring. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| HTTP client → POST /api/labels/:deviceID/print | Unauthenticated call can trigger printer hardware | +| SSE stream → browser client | Server pushes device state; no user input flows back | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-08 | Spoofing | POST /api/labels | accept | Solo-operator homelab on LAN; no auth on HWLab backend per current design (see PITFALLS.md security note — bearer token deferred to post-MVP) | +| T-04-09 | Denial of Service | print endpoint | mitigate | Rate limit print calls to 1/second using chi middleware or a simple lastPrintTime check — prevents runaway label waste | +| T-04-10 | Information Disclosure | SSE stream | accept | Device VID/PID and names are not sensitive; stream is LAN-only | +| T-04-11 | Denial of Service | SSE goroutine leak | mitigate | Handler returns on r.Context().Done(); verified by Test 5 | + + + +1. `go test ./internal/printer/... ./internal/api/handlers/... -v` — all tests pass +2. `go build ./cmd/hwlab/...` — compiles with updated router signature +3. Print endpoint test: POST /api/labels/1/print with mock returns 200 {"status":"printed"} +4. SSE test: GET /api/usb/events delivers "data: {...}" on event, exits on context cancel + + + +- PrinterDriver interface with Connect/Print/Disconnect +- MockDriver saves PNG to /tmp, usable without hardware +- PrtQutieDriver stub returns ErrNoDevice — safe to call, clearly marked TODO(hardware) +- POST /api/labels/:deviceID/print works end-to-end with mock driver +- GET /api/usb/events streams SSE with keepalive and clean goroutine teardown +- router.go updated, main.go starts USB Manager and uses MockDriver + + + +After completion, create `.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md` + diff --git a/.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md b/.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md new file mode 100644 index 0000000..bffb7e3 --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md @@ -0,0 +1,254 @@ +--- +phase: 04-usb-manager-label-printing +plan: "04" +type: execute +wave: 3 +depends_on: [04-03] +files_modified: + - internal/api/handlers/intake.go + - internal/api/handlers/intake_test.go +autonomous: true +requirements: [LBL-05] +must_haves: + truths: + - "After a successful intake that creates a NetBox record, a label is automatically printed" + - "If the printer is unavailable (ErrNoDevice or ErrNotConnected), intake succeeds without printing and the response includes a print_skipped flag" + - "Auto-print does not block intake — printer errors are logged and surfaced in response, not returned as HTTP 500" + - "Existing intake tests continue to pass unchanged (nil printer = no-op)" + artifacts: + - path: internal/api/handlers/intake.go + provides: "Updated IntakeHandler accepting optional IntakePrinter; auto-prints after record creation" + exports: [IntakeHandler, IntakePrinter] + - path: internal/api/handlers/intake_test.go + provides: "Updated tests covering auto-print success, printer error (non-fatal), nil printer (no-op)" + key_links: + - from: internal/api/handlers/intake.go + to: internal/printer/driver.go + via: "IntakePrinter interface satisfied by *printer.MockDriver or *printer.PrtQutieDriver" + pattern: "IntakePrinter" + - from: internal/api/handlers/intake.go + to: internal/labels/renderer.go + via: "calls labels.RenderStandard(d) after device creation" + pattern: "labels\\.RenderStandard" +--- + + +Integrate auto-print into the intake flow: after a NetBox record is created successfully, the intake handler calls the label renderer and printer driver. Printer failures must not fail the intake — they are non-fatal and surfaced in the response. + +Purpose: Delivers LBL-05 — label printing as the final step of AI intake without leaving the intake screen. The intake handler already has a clean dependency injection pattern (IntakeOrchestrator, IntakeNetBoxClient, etc.) — follow the same pattern to add IntakePrinter. + +Output: Updated intake handler with optional auto-print, updated tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md +@internal/api/handlers/intake.go +@internal/api/handlers/intake_test.go + + + + + +From internal/api/handlers/intake.go (existing, must read before editing): +```go +type IntakeHandler struct { + orchestrator IntakeOrchestrator + netboxClient IntakeNetBoxClient + catalogUpdater IntakeCatalogUpdater + waq IntakeWAQ // may be nil + deviceTypeID int32 + roleID int32 + siteID int32 + quickAddEnabled bool + quickAddThresh float64 +} + +func NewIntakeHandler( + orch IntakeOrchestrator, + nb IntakeNetBoxClient, + cu IntakeCatalogUpdater, + waq IntakeWAQ, + deviceTypeID, roleID, siteID int32, + quickAddEnabled bool, + quickAddThresh float64, +) *IntakeHandler +``` + +IntakeResponse (existing, to be extended with PrintSkipped): +```go +type IntakeResponse struct { + DeviceID int64 `json:"device_id"` + HWID string `json:"hw_id"` + CatalogStatus string `json:"catalog_status"` + // ... other fields +} +``` + +From internal/printer/driver.go (Plan 04-03): +```go +type PrinterDriver interface { + Connect() error + Print(bitmap []byte, width, height int) error + Disconnect() error +} +var ErrNoDevice = errors.New("printer: no device found matching VID/PID") +var ErrNotConnected = errors.New("printer: not connected") + +func ImageToRawBitmap(img image.Image) ([]byte, int, int) +``` + +From internal/labels/renderer.go (Plan 04-02): +```go +type LabelData struct { HWID, Name, SpecLine string } +func RenderStandard(d LabelData) (image.Image, error) +func IsCableDevice(d netbox.Device) bool +``` + +From internal/netbox/types.go: +```go +type Device struct { ID int; Name string; AssetTag string; CustomFields CustomFields } +``` + + + + + + Task 1: Add IntakePrinter interface + auto-print to intake handler + internal/api/handlers/intake.go, internal/api/handlers/intake_test.go + + - Test 1: Successful intake with a mock printer that returns nil — response contains print_skipped=false + - Test 2: Successful intake with a mock printer that returns ErrNoDevice — response contains print_skipped=true, HTTP still 201 + - Test 3: Successful intake with nil printer — response contains print_skipped=true (no-op path), no panic + - Test 4: Printer error does NOT cause intake to return 500 — device record was created, so 201 is returned + - Test 5: All 6 existing intake tests continue to pass (nil printer = existing behavior preserved) + + +Read `internal/api/handlers/intake.go` fully before editing. + +Add to `internal/api/handlers/intake.go`: + +1. New interface (follow the existing interface pattern in this file): +```go +// IntakePrinter is the optional printer used to auto-print a label after intake. +// If nil, label printing is skipped. Printer errors are non-fatal. +type IntakePrinter interface { + Print(bitmap []byte, width, height int) error +} +``` + +2. Add `printer IntakePrinter` field to `IntakeHandler` struct (after `waq`). + +3. Update `NewIntakeHandler` signature — add `p IntakePrinter` as the last parameter: +```go +func NewIntakeHandler( + orch IntakeOrchestrator, + nb IntakeNetBoxClient, + cu IntakeCatalogUpdater, + waq IntakeWAQ, + deviceTypeID, roleID, siteID int32, + quickAddEnabled bool, + quickAddThresh float64, + p IntakePrinter, // NEW — may be nil +) *IntakeHandler +``` + +4. Update `IntakeResponse` — add `PrintSkipped bool`: +```go +type IntakeResponse struct { + // ... existing fields unchanged ... + PrintSkipped bool `json:"print_skipped"` +} +``` + +5. In `ServeHTTP`, after the NetBox record is successfully created (after `catalogUpdater.UpdateCatalogStatus` succeeds), add the auto-print step: +```go +// Auto-print label (non-fatal: printer errors skip printing but don't fail intake) +resp.PrintSkipped = true +if h.printer != nil { + // Fetch the created device to build LabelData + // Use the deviceID and HWID already available in the response + labelData := labels.LabelData{ + HWID: hwid, // already allocated earlier in the handler + Name: result.Name, // from AI IntakeResult + SpecLine: result.SpecLine, // from AI IntakeResult (top spec summary) + } + img, renderErr := labels.RenderStandard(labelData) + if renderErr == nil { + bitmap, w, h := printer.ImageToRawBitmap(img) + printErr := h.printer.Print(bitmap, w, h) + if printErr == nil { + resp.PrintSkipped = false + } else { + log.Printf("auto-print skipped: %v", printErr) + } + } else { + log.Printf("label render failed: %v", renderErr) + } +} +``` + +Note: `result.SpecLine` may not exist yet on `ai.IntakeResult`. Check the struct definition in `internal/ai/` — if there is no SpecLine field, use `result.AINotes` or the first spec from `result.Specs` as a fallback. Do NOT add a field to IntakeResult if it does not already exist — use what is there. + +6. Update `cmd/hwlab/main.go` to pass the MockDriver instance to `NewIntakeHandler` as the last argument. + +7. Update all existing `newHandler(...)` calls in `intake_test.go` to pass `nil` as the last argument — preserving the 6 existing test behaviors exactly. + +Write 4 new tests (Tests 1-4) in `intake_test.go` using a `mockPrinter` type: +```go +type mockPrinter struct { + returnErr error + called bool +} +func (m *mockPrinter) Print(bitmap []byte, w, h int) error { + m.called = true + return m.returnErr +} +``` + + + cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestIntake" 2>&1 | tail -30 + + All intake tests pass (existing 6 + new 4). `go build ./cmd/hwlab/...` succeeds. IntakePrinter interface accepted as nil safely. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| intake flow → printer | Label printed after record creation; print failure must not corrupt the intake record | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-12 | Tampering | auto-print data | accept | LabelData derived from NetBox-confirmed record; no user-supplied raw input flows to printer at this stage | +| T-04-13 | Denial of Service | print loop in intake | mitigate | Print is called once per intake, synchronous, with its own error boundary — cannot loop or recurse | + + + +1. `go test ./internal/api/handlers/... -v` — all tests pass (old + new) +2. `go build ./cmd/hwlab/...` — compiles with updated NewIntakeHandler signature +3. Test with nil printer: intake returns 201 with print_skipped=true +4. Test with mock printer returning ErrNoDevice: intake returns 201 with print_skipped=true + + + +- IntakePrinter interface added to intake.go +- Auto-print step fires after NetBox record creation +- Printer failure (any error) sets print_skipped=true and logs — never 500 +- nil printer behaves as no-op (print_skipped=true) +- All existing intake tests pass unchanged (nil passed as printer) +- New tests cover the 4 printer scenarios + + + +After completion, create `.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md` + diff --git a/.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md b/.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md new file mode 100644 index 0000000..5b9527b --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md @@ -0,0 +1,329 @@ +--- +phase: 04-usb-manager-label-printing +plan: "05" +type: execute +wave: 3 +depends_on: [04-03] +files_modified: + - web/src/components/USBStatusBar.tsx + - web/src/hooks/useUSBEvents.ts + - web/src/pages/DashboardPage.tsx +autonomous: true +requirements: [USB-04] +must_haves: + truths: + - "USB device connect/disconnect events appear in the frontend in real time" + - "Dashboard shows a USB status bar with connected device names and roles" + - "Print Label button on item cards calls POST /api/labels/:id/print and shows a toast on success/failure" + - "USB status bar updates without a page reload when a device is plugged or unplugged" + artifacts: + - path: web/src/hooks/useUSBEvents.ts + provides: "React hook subscribing to GET /api/usb/events SSE stream, returns DeviceEvent[]" + exports: [useUSBEvents, DeviceEvent, DeviceState] + - path: web/src/components/USBStatusBar.tsx + provides: "USBStatusBar component showing connected USB devices with role icons" + exports: [USBStatusBar] + - path: web/src/pages/DashboardPage.tsx + provides: "Updated dashboard with USBStatusBar in header area and Print Label quick action on item cards" + key_links: + - from: web/src/hooks/useUSBEvents.ts + to: GET /api/usb/events + via: "new EventSource('/api/usb/events') with onmessage handler" + pattern: "EventSource" + - from: web/src/pages/DashboardPage.tsx + to: POST /api/labels/:id/print + via: "fetch('/api/labels/${id}/print', {method:'POST'}) in handlePrintLabel" + pattern: "api/labels" +--- + + +Build the frontend USB status bar and wire the print label quick action on dashboard item cards. + +Purpose: Delivers the user-visible half of USB-04 — device events arrive from the SSE stream and update the UI without polling or page reloads. Also surfaces the print action from the dashboard. + +Output: `useUSBEvents` hook, `USBStatusBar` component, updated `DashboardPage` with print button. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md +@web/src/pages/DashboardPage.tsx + + + + + +SSE message format — each event is `data: {JSON}\n\n` where JSON is: +```typescript +interface DeviceEvent { + VIDPID: string; // e.g. "0525:a4a7" + Spec: { + VID: string; + PID: string; + Name: string; // e.g. "PRT Qutie" + Role: number; // 0=Printer, 1=CableTester, 2=Unknown + BaudRate: number; + }; + State: number; // 0=Disconnected, 1=Connected +} + +type DeviceState = 0 | 1; +const StateDisconnected: DeviceState = 0; +const StateConnected: DeviceState = 1; +``` + +Print endpoint response: +```typescript +// POST /api/labels/:deviceID/print +// 200: { status: "printed" } +// 200 with print_skipped: { status: "ok", print_skipped: true } (printer unavailable) +// 404: { error: "device not found" } +// 500: { error: "..." } +``` + +ClickHouse design system (from CLAUDE.md global): +- Background: #000000 (pure black) +- Accent: #faff69 (neon volt) +- Status indicators: connected = #22c55e (green-500), disconnected = #ef4444 (red-500) +- Font: existing Tailwind classes, no custom CSS + +Existing DashboardPage patterns (read the file before editing): +- Uses TanStack Query for inventory data +- Uses react-hot-toast for notifications +- Item cards are rendered via ItemCard component or inline map +- Quick actions are icon buttons in the card's action area + + + + + + Task 1: useUSBEvents hook + USBStatusBar component + web/src/hooks/useUSBEvents.ts, web/src/components/USBStatusBar.tsx + +Read `web/src/pages/DashboardPage.tsx` to understand the existing component patterns before writing. + +Create `web/src/hooks/useUSBEvents.ts`: + +```typescript +import { useEffect, useState } from 'react'; + +export const StateDisconnected = 0; +export const StateConnected = 1; + +export interface DeviceSpec { + VID: string; + PID: string; + Name: string; + Role: number; // 0=Printer, 1=CableTester, 2=Unknown + BaudRate: number; +} + +export interface DeviceEvent { + VIDPID: string; + Spec: DeviceSpec; + State: number; +} + +/** + * useUSBEvents subscribes to the GET /api/usb/events SSE stream and maintains + * the current map of connected USB devices. + * + * Returns: connectedDevices (map of VIDPID → DeviceSpec) and the last raw event. + */ +export function useUSBEvents(): { + connectedDevices: Map; + lastEvent: DeviceEvent | null; +} { + const [connectedDevices, setConnectedDevices] = useState>(new Map()); + const [lastEvent, setLastEvent] = useState(null); + + useEffect(() => { + const source = new EventSource('/api/usb/events'); + + source.onmessage = (e) => { + try { + const event: DeviceEvent = JSON.parse(e.data); + setLastEvent(event); + setConnectedDevices((prev) => { + const next = new Map(prev); + if (event.State === StateConnected) { + next.set(event.VIDPID, event.Spec); + } else { + next.delete(event.VIDPID); + } + return next; + }); + } catch { + // Malformed SSE data — ignore, keep existing state + } + }; + + source.onerror = () => { + // SSE error (server down, network issue) — EventSource auto-reconnects + // No state change needed; devices remain as last known + }; + + return () => { + source.close(); + }; + }, []); + + return { connectedDevices, lastEvent }; +} +``` + +Create `web/src/components/USBStatusBar.tsx`: + +```tsx +import { Printer, Cable, Usb } from 'lucide-react'; +import { useUSBEvents, DeviceSpec } from '../hooks/useUSBEvents'; + +const roleIcon = (role: number) => { + switch (role) { + case 0: return ; + case 1: return ; + default: return ; + } +}; + +/** + * USBStatusBar displays connected USB devices in a compact horizontal bar. + * Shows a dot indicator per device; empty state shows "No USB devices". + * Uses ClickHouse design system: black bg, neon volt accent, green for connected. + */ +export function USBStatusBar() { + const { connectedDevices } = useUSBEvents(); + const devices = Array.from(connectedDevices.entries()); + + if (devices.length === 0) { + return ( +
+ + No USB devices +
+ ); + } + + return ( +
+ {devices.map(([vidpid, spec]: [string, DeviceSpec]) => ( +
+ + {roleIcon(spec.Role)} + {spec.Name} +
+ ))} +
+ ); +} +``` +
+ + cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | tail -20 + + TypeScript compilation clean. useUSBEvents hook and USBStatusBar component created with no type errors. +
+ + + Task 2: Wire USBStatusBar + Print Label button into DashboardPage + web/src/pages/DashboardPage.tsx + +Read `web/src/pages/DashboardPage.tsx` fully before editing. + +Make two targeted additions to DashboardPage: + +**1. Import and place USBStatusBar** in the page header area (above the inventory grid, alongside the existing filter controls). Find the header/toolbar section and add: +```tsx +import { USBStatusBar } from '../components/USBStatusBar'; + +// In the header JSX, alongside existing controls: + +``` + +**2. Add Print Label quick action button** to each item card. Find where item quick actions are rendered (look for the existing "Print Label" or "View in NetBox" / "Edit" action area). If a print action already exists as a stub, wire it; if not, add a Printer icon button: + +```tsx +import toast from 'react-hot-toast'; +import { Printer } from 'lucide-react'; + +// In the item card action area (per item): +const handlePrintLabel = async (deviceId: number) => { + const toastId = toast.loading('Printing label...'); + try { + const res = await fetch(`/api/labels/${deviceId}/print`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) { + toast.error(data.error ?? 'Print failed', { id: toastId }); + return; + } + if (data.print_skipped) { + toast('Label queued — printer not connected', { id: toastId, icon: '⚠️' }); + } else { + toast.success('Label printed', { id: toastId }); + } + } catch { + toast.error('Network error', { id: toastId }); + } +}; + +// Button (in each card's action area): + +``` + +Move `handlePrintLabel` outside the map callback (define once at component level, not per-render). + +Do not restructure the existing DashboardPage layout — make additive changes only. Preserve all existing functionality (grid/list toggle, filters, item navigation). + + + cd /home/mikkel/homelabby/web && npx tsc --noEmit && npx eslint src/pages/DashboardPage.tsx --max-warnings 0 2>&1 | tail -20 + + TypeScript and ESLint clean. USBStatusBar renders in dashboard header. Print Label button present on each item card. + + +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| SSE stream → React state | Parsed JSON from server updates UI state | +| Print button → POST /api/labels | User-triggered HTTP call from browser | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-14 | Tampering | SSE JSON parse | mitigate | Wrap JSON.parse in try/catch; malformed data is ignored, state unchanged | +| T-04-15 | Denial of Service | EventSource reconnect loop | accept | Browser EventSource auto-reconnects with backoff; solo-operator, not a threat vector | +| T-04-16 | Information Disclosure | Device names in SSE | accept | Device names visible on LAN only; PRT Qutie name is not sensitive | + + + +1. `npx tsc --noEmit` in web/ — clean +2. `npx eslint src/` — no errors or warnings on new files +3. `npm run build` — production build succeeds + + + +- useUSBEvents hook subscribes to /api/usb/events SSE and maintains connected device map +- USBStatusBar shows connected devices with role icons and green status dots; empty state when none +- DashboardPage header includes USBStatusBar +- Each item card has a Printer icon button wired to POST /api/labels/:id/print +- Print success shows success toast; printer unavailable shows warning toast; error shows error toast +- No TypeScript errors, no ESLint errors + + + +After completion, create `.planning/phases/04-usb-manager-label-printing/04-05-SUMMARY.md` +