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

13 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-usb-manager-label-printing 01 execute 1
internal/usb/manager.go
internal/usb/manager_test.go
internal/usb/device.go
go.mod
go.sum
true
USB-01
USB-02
USB-03
truths artifacts key_links
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
path provides exports
internal/usb/device.go DeviceSpec, DeviceState, DeviceEvent, Command types + KnownDevices registry
DeviceSpec
DeviceState
DeviceEvent
Command
KnownDevices
path provides exports
internal/usb/manager.go USBManager with goroutine-per-device, VID/PID poll loop, reconnect
Manager
NewManager
Manager.Start
Manager.Stop
Manager.Events
path provides min_lines
internal/usb/manager_test.go Unit tests covering goroutine count stability and reconnect logic with mock serial port 80
from to via pattern
internal/usb/manager.go go.bug.st/serial serial.GetPortsList() + port.Open() per VID/PID match serial.GetPortsList
from to via pattern
internal/usb/manager.go internal/usb/device.go KnownDevices lookup keyed by VID:PID string 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.

<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.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):

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:

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`:
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.

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

<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>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md`