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>
13 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-usb-manager-label-printing | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/research/PITFALLS.mdExisting 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):
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
Create internal/usb/device.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 -jsonand parse the JSON tree for "vendor_id" and "product_id" fields, then cross-reference withserial.GetPortsList()to find the matching tty/cu path - Return only devices whose VID:PID appears in KnownDevices
- If
system_profileris 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.
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:
- Every
pollInterval, callenumerateFunc()to get map[VIDPID]portPath - For each VID:PID in KnownDevices: compare to previous snapshot
- Newly present → spawn
deviceLoop(ctx, spec, portPath, cmdChan)goroutine, emit Connected event - Newly absent → send CmdClose to cmdChan, wait for goroutine to exit via done channel, emit Disconnected event
- Update snapshot
Device loop (deviceLoop private function):
- Open serial port with the given path
- Launch inner read goroutine that reads until context cancelled or port closed
- Main loop: select on cmdChan and context.Done()
- On CmdWrite: write payload to port
- On CmdClose or context cancellation: cancel inner context, call port.Close(), drain any pending events, return
- 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
donechannel (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.
<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> |
<success_criteria>
internal/usbpackage 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>