--- phase: 04-usb-manager-label-printing plan: "01" type: execute wave: 1 depends_on: [] files_modified: - internal/usb/manager.go - internal/usb/manager_test.go - internal/usb/device.go - go.mod - go.sum autonomous: true requirements: [USB-01, USB-02, USB-03] must_haves: truths: - "USB Manager discovers devices by VID/PID, never by path" - "Each connected device runs in its own goroutine with command and event channels" - "Unplugging a device causes the owning goroutine to exit cleanly without leaking" - "Replugging the same device (same VID/PID) reconnects automatically without operator action" - "Goroutine count is stable across 5 consecutive unplug/replug cycles" artifacts: - path: internal/usb/device.go provides: "DeviceSpec, DeviceState, DeviceEvent, Command types + KnownDevices registry" exports: [DeviceSpec, DeviceState, DeviceEvent, Command, KnownDevices] - path: internal/usb/manager.go provides: "USBManager with goroutine-per-device, VID/PID poll loop, reconnect" exports: [Manager, NewManager, Manager.Start, Manager.Stop, Manager.Events] - path: internal/usb/manager_test.go provides: "Unit tests covering goroutine count stability and reconnect logic with mock serial port" min_lines: 80 key_links: - from: internal/usb/manager.go to: go.bug.st/serial via: "serial.GetPortsList() + port.Open() per VID/PID match" pattern: "serial\\.GetPortsList" - from: internal/usb/manager.go to: internal/usb/device.go via: "KnownDevices lookup keyed by VID:PID string" pattern: "KnownDevices" --- Build the `internal/usb` package: USB Manager with goroutine-per-device model, VID/PID-based enumeration, reconnect handling, and leak-safe goroutine teardown. Purpose: Foundation for all USB peripheral interaction in Phase 4 and Phase 5. The PRT Qutie and future Treedix testers must be enumerated by stable VID/PID identity, not ephemeral macOS paths. Goroutine leak prevention is critical for a long-running daemon. Output: `internal/usb` package with Manager, DeviceSpec registry, and passing tests. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/research/PITFALLS.md # Existing code this plan extends @internal/api/router.go @internal/config/config.go From go.mod — module path: ``` module git.georgsen.dk/hwlab go 1.23.0 ``` From internal/config/config.go (viper-based, add USB section): The existing config pattern uses `viper.GetString("key")`. New USB config keys: - `usb.poll_interval_ms` (default 2000) - `usb.prt_qutie_vid` (default "0525") - `usb.prt_qutie_pid` (default "a4a7") go.bug.st/serial API (to be added as dependency): ```go import "go.bug.st/serial" // Enumerate all current serial ports ports, err := serial.GetPortsList() // returns []string of /dev/cu.* paths // Open a port mode := &serial.Mode{BaudRate: 9600} port, err := serial.Open(path, mode) // Read (blocks until data or close) n, err := port.Read(buf) // Unblock a blocked Read from another goroutine port.Close() // Get port details (VID/PID) — macOS requires ioreg or system_profiler // go.bug.st/serial does NOT expose VID/PID directly on macOS. // Use: serial.GetDetailedPortsList() on platforms that support it, // OR shell out to: system_profiler SPUSBDataType -json // See task action for the chosen approach. ``` Important pitfall (from PITFALLS.md Pitfall 1 + Pitfall 2): - NEVER store device paths long-term — re-resolve on every reconnect - Use context.Context cancellation + done channel before port.Close() to avoid goroutine leak - Test with runtime.NumGoroutine() before/after replug cycles Task 1: Device types, KnownDevices registry, and VID/PID enumeration helper internal/usb/device.go, internal/usb/device_test.go, go.mod, go.sum - Test 1: KnownDevices["0525:a4a7"] returns a DeviceSpec with Role == RolePrinter - Test 2: enumerateByVIDPID with a mock port list returns only ports whose ioreg output contains the target VID/PID - Test 3: DeviceSpec.String() returns "VID:PID (Name)" format for logging - Test 4: ParseVIDPID("0525:a4a7") returns vid="0525", pid="a4a7", nil error - Test 5: ParseVIDPID("badvalue") returns error Run: `go get go.bug.st/serial@latest` to add dependency. Create `internal/usb/device.go`: ```go package usb // DeviceRole classifies what a USB device is used for in HWLab. type DeviceRole int const ( RolePrinter DeviceRole = iota RoleCableTester // reserved for Phase 5 RoleUnknown ) // DeviceSpec describes a known USB peripheral by VID and PID. type DeviceSpec struct { VID string // 4-hex-digit vendor ID, e.g. "0525" PID string // 4-hex-digit product ID, e.g. "a4a7" Name string // human-readable label for logs Role DeviceRole BaudRate int // serial baud rate; 0 means use 9600 default } func (s DeviceSpec) VIDPID() string { return s.VID + ":" + s.PID } func (s DeviceSpec) String() string { return s.VIDPID() + " (" + s.Name + ")" } // KnownDevices maps "VID:PID" to DeviceSpec for all peripherals HWLab manages. // Update VID/PID values after hardware characterization on 2026-04-13. var KnownDevices = map[string]DeviceSpec{ "0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600}, // Phase 5: Treedix testers will be added here after characterization } // DeviceState represents the current connection status of a managed device. type DeviceState int const ( StateDisconnected DeviceState = iota StateConnected ) // DeviceEvent is emitted on the Manager.Events() channel for any state change. type DeviceEvent struct { VIDPID string Spec DeviceSpec State DeviceState } // Command is sent to a device goroutine via its command channel. type Command struct { Type CommandType Payload []byte Reply chan<- error // caller closes or receives result } type CommandType int const ( CmdWrite CommandType = iota CmdClose ) ``` Create `internal/usb/enumerate.go` with the VID/PID enumeration logic: - `enumerateConnected() (map[string]string, error)` — returns map[VIDPID]portPath - On macOS: shell out to `system_profiler SPUSBDataType -json` and parse the JSON tree for "vendor_id" and "product_id" fields, then cross-reference with `serial.GetPortsList()` to find the matching tty/cu path - Return only devices whose VID:PID appears in KnownDevices - If `system_profiler` is unavailable (Linux CI): fall back to scanning `/sys/bus/usb/devices/` or returning empty map with no error (graceful degradation) - The function must be testable: accept an optional override func for the shell command output (dependency injection via package-level var `sysProfilerCmd func() ([]byte, error)`) Write `internal/usb/device_test.go` covering the 5 behavior tests listed above. For enumerateByVIDPID tests, override `sysProfilerCmd` with a fixture returning known JSON before calling the function. cd /home/mikkel/homelabby && go test ./internal/usb/... -run "TestDeviceSpec|TestParseVIDPID|TestEnumerate" -v 2>&1 | tail -20 All 5 behavior tests pass. `go build ./...` succeeds. go.mod includes go.bug.st/serial. Task 2: USB Manager — goroutine-per-device, poll loop, reconnect, leak-safe teardown internal/usb/manager.go, internal/usb/manager_test.go - Test 1: Manager.Start() with a mock enumeration returning one device spawns exactly 1 device goroutine - Test 2: When mock enumeration transitions device from present→absent→present, a DeviceEvent{Disconnected} then DeviceEvent{Connected} is emitted on Events() channel - Test 3: Manager.Stop() causes all device goroutines to exit; runtime.NumGoroutine() returns to baseline within 500ms - Test 4: Manager.Send(vidpid, cmd) returns ErrDeviceNotConnected when the device is absent - Test 5: Goroutine count is stable (±2) across 5 simulated unplug/replug cycles using the mock enumerator Create `internal/usb/manager.go`: ```go package usb import ( "context" "log" "sync" "time" ) // Manager owns all USB device goroutines and emits DeviceEvents to subscribers. type Manager struct { pollInterval time.Duration events chan DeviceEvent cmdChans map[string]chan Command // keyed by VID:PID mu sync.Mutex cancel context.CancelFunc wg sync.WaitGroup // enumerateFunc is injectable for tests; defaults to enumerateConnected enumerateFunc func() (map[string]string, error) } func NewManager(pollInterval time.Duration) *Manager { ... } // Start begins the poll loop. Call Stop to shut down cleanly. func (m *Manager) Start(ctx context.Context) { ... } // Stop cancels all device goroutines and waits for them to exit. func (m *Manager) Stop() { ... } // Events returns the read-only channel of DeviceEvent notifications. func (m *Manager) Events() <-chan DeviceEvent { return m.events } // Send delivers a Command to the named device. Returns ErrDeviceNotConnected if absent. func (m *Manager) Send(vidpid string, cmd Command) error { ... } ``` Poll loop behavior: 1. Every `pollInterval`, call `enumerateFunc()` to get map[VIDPID]portPath 2. For each VID:PID in KnownDevices: compare to previous snapshot 3. Newly present → spawn `deviceLoop(ctx, spec, portPath, cmdChan)` goroutine, emit Connected event 4. Newly absent → send CmdClose to cmdChan, wait for goroutine to exit via done channel, emit Disconnected event 5. Update snapshot Device loop (`deviceLoop` private function): 1. Open serial port with the given path 2. Launch inner read goroutine that reads until context cancelled or port closed 3. Main loop: select on cmdChan and context.Done() 4. On CmdWrite: write payload to port 5. On CmdClose or context cancellation: cancel inner context, call port.Close(), drain any pending events, return 6. Defer wg.Done() Goroutine leak prevention (per PITFALLS.md Pitfall 2): - Each deviceLoop receives a child context derived from the Manager's root context - Before port.Close(), cancel the child context - Inner read goroutine selects on context.Done(); exits if context is done before next Read returns - Use a `done` channel (close it when deviceLoop exits) so the poll loop can wait with a timeout before marking the device as gone from cmdChans Test setup: inject a mock `enumerateFunc` that returns predefined snapshots per call number. Use `runtime.NumGoroutine()` before and after to verify leak-free teardown. cd /home/mikkel/homelabby && go test ./internal/usb/... -v -race -timeout 30s 2>&1 | tail -30 All tests pass with -race. No goroutine leak detected. `go vet ./internal/usb/...` clean. ## Trust Boundaries | Boundary | Description | |----------|-------------| | host OS → serial port | USB device data enters as raw bytes from a physical peripheral | | serial port → Manager | Device goroutine reads bytes; poisoned data could cause parse panics | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-04-01 | Tampering | deviceLoop read buffer | mitigate | Cap read buffer at 4096 bytes; validate length before forwarding to command handler | | T-04-02 | Denial of Service | poll loop | accept | Poll interval is configurable (default 2s); single-user homelab, no adversarial hotplug expected | | T-04-03 | Information Disclosure | system_profiler output | accept | USB device metadata is not sensitive; output is local-only, never logged to external sink | | T-04-04 | Elevation of Privilege | serial port open | accept | /dev/cu.* permissions on macOS require the user running the process to be in the `dialout` group or have standard user access; HWLab runs as the operator user | 1. `go test ./internal/usb/... -race -v` — all tests pass, race detector clean 2. `go build ./...` — compiles with new dependency 3. `go vet ./internal/usb/...` — no issues 4. Goroutine stability: TestGoroutineStability passes (count ±2 across 5 replug cycles) - `internal/usb` package exists with Manager, DeviceSpec, DeviceEvent, Command types - KnownDevices registry includes PRT Qutie VID:PID placeholder - VID/PID enumeration uses system_profiler on macOS, never stores paths between polls - goroutine-per-device with context+done-channel teardown - All tests pass with -race flag - No goroutine count growth in stability test After completion, create `.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md`