docs(04): create phase 4 plans — USB manager, label printing, SSE, intake integration

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 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:41:26 +00:00
parent 6a8a4658a7
commit 77bf4ebfd6
6 changed files with 1836 additions and 4 deletions

View file

@ -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 | - |

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- Key contracts the executor needs. No codebase exploration required. -->
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") <!-- placeholder — update after hardware arrives -->
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
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Device types, KnownDevices registry, and VID/PID enumeration helper</name>
<files>internal/usb/device.go, internal/usb/device_test.go, go.mod, go.sum</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/usb/... -run "TestDeviceSpec|TestParseVIDPID|TestEnumerate" -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All 5 behavior tests pass. `go build ./...` succeeds. go.mod includes go.bug.st/serial.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: USB Manager — goroutine-per-device, poll loop, reconnect, leak-safe teardown</name>
<files>internal/usb/manager.go, internal/usb/manager_test.go</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/usb/... -v -race -timeout 30s 2>&amp;1 | tail -30</automated>
</verify>
<done>All tests pass with -race. No goroutine leak detected. `go vet ./internal/usb/...` clean.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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)
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@internal/netbox/types.go
</context>
<interfaces>
<!-- Key contracts the executor needs. -->
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)
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: QR code generation + standard label renderer</name>
<files>internal/labels/renderer.go, internal/labels/renderer_test.go, go.mod, go.sum</files>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -run "TestRenderStandard" -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All 5 behavior tests pass. go.mod includes github.com/skip2/go-qrcode and golang.org/x/image.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Cable label renderer + IsCableDevice detection helper</name>
<files>internal/labels/cable.go, internal/labels/cable_test.go</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All label tests pass. `go build ./...` succeeds. Both RenderStandard and RenderCable produce valid image.Image values.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@internal/api/router.go
@internal/netbox/types.go
@internal/api/handlers/inventory.go
</context>
<interfaces>
<!-- Key contracts from plans 04-01 and 04-02 that this plan builds against. -->
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
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: PrinterDriver interface, MockDriver, PrtQutieDriver stub</name>
<files>internal/printer/driver.go, internal/printer/mock_driver.go, internal/printer/prt_qutie.go, internal/printer/driver_test.go</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/printer/... -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All 5 tests pass. PrtQutieDriver.Connect() returns ErrNoDevice (stub). MockDriver.Print() saves PNG and returns nil for valid input.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: LabelHandler + USBEventsHandler + router wiring</name>
<files>internal/api/handlers/label.go, internal/api/handlers/label_test.go, internal/api/handlers/usb_events.go, internal/api/router.go</files>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestLabel|TestUSBEvents" 2>&amp;1 | tail -25</automated>
</verify>
<done>All handler tests pass. `go build ./cmd/hwlab/...` succeeds with updated NewRouter signature and main.go wiring.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@internal/api/handlers/intake.go
@internal/api/handlers/intake_test.go
</context>
<interfaces>
<!-- Key contracts from prior plans. -->
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 }
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add IntakePrinter interface + auto-print to intake handler</name>
<files>internal/api/handlers/intake.go, internal/api/handlers/intake_test.go</files>
<behavior>
- 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)
</behavior>
<action>
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
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestIntake" 2>&amp;1 | tail -30</automated>
</verify>
<done>All intake tests pass (existing 6 + new 4). `go build ./cmd/hwlab/...` succeeds. IntakePrinter interface accepted as nil safely.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@web/src/pages/DashboardPage.tsx
</context>
<interfaces>
<!-- SSE event shape from Plan 04-03 (usb_events.go): -->
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
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: useUSBEvents hook + USBStatusBar component</name>
<files>web/src/hooks/useUSBEvents.ts, web/src/components/USBStatusBar.tsx</files>
<action>
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<string, DeviceSpec>;
lastEvent: DeviceEvent | null;
} {
const [connectedDevices, setConnectedDevices] = useState<Map<string, DeviceSpec>>(new Map());
const [lastEvent, setLastEvent] = useState<DeviceEvent | null>(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 <Printer className="w-4 h-4" />;
case 1: return <Cable className="w-4 h-4" />;
default: return <Usb className="w-4 h-4" />;
}
};
/**
* 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 (
<div className="flex items-center gap-2 px-3 py-1.5 bg-black border border-white/10 rounded text-xs text-white/40">
<Usb className="w-3.5 h-3.5" />
<span>No USB devices</span>
</div>
);
}
return (
<div className="flex items-center gap-3 px-3 py-1.5 bg-black border border-white/10 rounded">
{devices.map(([vidpid, spec]: [string, DeviceSpec]) => (
<div key={vidpid} className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
{roleIcon(spec.Role)}
<span className="text-white/80">{spec.Name}</span>
</div>
))}
</div>
);
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&amp;1 | tail -20</automated>
</verify>
<done>TypeScript compilation clean. useUSBEvents hook and USBStatusBar component created with no type errors.</done>
</task>
<task type="auto">
<name>Task 2: Wire USBStatusBar + Print Label button into DashboardPage</name>
<files>web/src/pages/DashboardPage.tsx</files>
<action>
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:
<USBStatusBar />
```
**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):
<button
onClick={() => handlePrintLabel(item.id)}
className="p-1.5 rounded hover:bg-white/10 text-white/60 hover:text-[#faff69] transition-colors"
title="Print label"
>
<Printer className="w-4 h-4" />
</button>
```
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).
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit && npx eslint src/pages/DashboardPage.tsx --max-warnings 0 2>&amp;1 | tail -20</automated>
</verify>
<done>TypeScript and ESLint clean. USBStatusBar renders in dashboard header. Print Label button present on each item card.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-05-SUMMARY.md`
</output>