homelabby/.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md
Mikkel Georgsen 77bf4ebfd6 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>
2026-04-10 06:41:26 +00:00

322 lines
13 KiB
Markdown

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