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 ### 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 **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 **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): **Success Criteria** (what must be TRUE):
1. Inventory dashboard loads with grid/list toggle, item cards showing photo, HW ID, status, and key specs 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 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 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 5. Label printing completes as the final step of the AI intake workflow without leaving the intake screen
**Plans**: TBD **Plans**: 5 plans
**UI hint**: yes
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 ### 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 **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 | | 1. Foundation | 5/5 | Complete | 2026-04-10 |
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 | | 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
| 3. Dashboard & Intake UI | 5/5 | 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 | - | | 5. Cable Test Integration | 0/TBD | Not started | - |
| 6. Lab Advisor | 0/TBD | Not started | - | | 6. Lab Advisor | 0/TBD | Not started | - |
| 7. Research Agent & Search | 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>