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>
322 lines
13 KiB
Markdown
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>&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>&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>
|