homelabby/.planning/phases/04-usb-manager-label-printing/04-03-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

558 lines
20 KiB
Markdown

---
phase: 04-usb-manager-label-printing
plan: "03"
type: execute
wave: 2
depends_on: [04-01, 04-02]
files_modified:
- internal/printer/driver.go
- internal/printer/mock_driver.go
- internal/printer/prt_qutie.go
- internal/printer/driver_test.go
- internal/api/handlers/label.go
- internal/api/handlers/label_test.go
- internal/api/handlers/usb_events.go
- internal/api/router.go
autonomous: true
requirements: [LBL-04, USB-04]
must_haves:
truths:
- "PrinterDriver interface abstracts all driver implementations"
- "MockDriver logs print calls to stdout and saves PNG to /tmp/hwlab-label-*.png for visual inspection"
- "PrtQutieDriver implements PrinterDriver using go.bug.st/serial with a TODO stub for the real protocol"
- "POST /api/labels/:deviceID/print renders a label and sends it to the connected printer"
- "GET /api/usb/events streams SSE DeviceEvents from the USB Manager to connected browser clients"
artifacts:
- path: internal/printer/driver.go
provides: "PrinterDriver interface, ImageToRawBitmap() helper"
exports: [PrinterDriver, ImageToRawBitmap]
- path: internal/printer/mock_driver.go
provides: "MockDriver implementing PrinterDriver — saves PNG to /tmp"
exports: [MockDriver, NewMockDriver]
- path: internal/printer/prt_qutie.go
provides: "PrtQutieDriver implementing PrinterDriver via serial port"
exports: [PrtQutieDriver, NewPrtQutieDriver]
- path: internal/api/handlers/label.go
provides: "LabelHandler: POST /api/labels/:deviceID/print"
exports: [LabelHandler, NewLabelHandler]
- path: internal/api/handlers/usb_events.go
provides: "USBEventsHandler: GET /api/usb/events SSE stream"
exports: [USBEventsHandler, NewUSBEventsHandler]
- path: internal/api/router.go
provides: "Updated router wiring the two new endpoints"
key_links:
- from: internal/api/handlers/label.go
to: internal/labels/renderer.go
via: "calls labels.RenderStandard or labels.RenderCable based on IsCableDevice()"
pattern: "labels\\.Render"
- from: internal/api/handlers/label.go
to: internal/printer/driver.go
via: "PrinterDriver.Print(imageToRawBitmap(img))"
pattern: "driver\\.Print"
- from: internal/api/handlers/usb_events.go
to: internal/usb/manager.go
via: "reads from Manager.Events() channel and writes SSE lines"
pattern: "Manager\\.Events"
---
<objective>
Build the printer driver abstraction, PRT Qutie stub driver, mock driver, and wire two HTTP endpoints: `POST /api/labels/:deviceID/print` and `GET /api/usb/events` (SSE).
Purpose: The printer driver interface allows Phase 5 (and hardware arrival day) to swap in a real PRT Qutie driver without touching handlers. The SSE endpoint delivers live USB device state to the frontend in real time (per USB-04).
Output: `internal/printer` package + two new HTTP handlers wired into the chi router.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@internal/api/router.go
@internal/netbox/types.go
@internal/api/handlers/inventory.go
</context>
<interfaces>
<!-- Key contracts from plans 04-01 and 04-02 that this plan builds against. -->
From internal/usb/manager.go (created in Plan 04-01):
```go
// DeviceEvent emitted on connect/disconnect
type DeviceEvent struct {
VIDPID string
Spec DeviceSpec
State DeviceState // StateConnected or StateDisconnected
}
type Manager struct { ... }
func (m *Manager) Events() <-chan DeviceEvent
func (m *Manager) Send(vidpid string, cmd Command) error
```
From internal/usb/device.go (created in Plan 04-01):
```go
type DeviceRole int
const (
RolePrinter DeviceRole = iota
RoleCableTester
RoleUnknown
)
type DeviceSpec struct {
VID, PID, Name string
Role DeviceRole
BaudRate int
}
func (s DeviceSpec) VIDPID() string
```
From internal/labels/renderer.go (created in Plan 04-02):
```go
type LabelData struct {
HWID string
Name string
SpecLine string
}
type CableLabelData struct {
HWID string
Name string
USBVersion string
MaxSpeedGbps float64
MaxWatts int
TestDate string
}
func RenderStandard(d LabelData) (image.Image, error)
func RenderCable(d CableLabelData) (image.Image, error)
func IsCableDevice(d netbox.Device) bool
```
From internal/netbox/types.go:
```go
type Device struct {
ID int; Name string; AssetTag string
CustomFields CustomFields
}
type CustomFields struct {
HWID string; TestDate string; TestData string; AINotes string
}
```
From internal/api/router.go (current signature):
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler
```
This MUST be updated to accept the two new handlers as parameters.
Narrow interface pattern (follow existing codebase pattern from handlers/intake.go and handlers/inventory.go):
- Define interface types in handler files (e.g., LabelNetBoxClient, LabelPrinter)
- Concrete types satisfy interfaces — no direct package imports in handler constructors
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: PrinterDriver interface, MockDriver, PrtQutieDriver stub</name>
<files>internal/printer/driver.go, internal/printer/mock_driver.go, internal/printer/prt_qutie.go, internal/printer/driver_test.go</files>
<behavior>
- Test 1: MockDriver.Print(validBitmap) saves a PNG file to /tmp/hwlab-label-*.png and returns nil
- Test 2: MockDriver.Print(nil) returns ErrEmptyBitmap without panicking
- Test 3: MockDriver.Connect() returns nil; Disconnect() returns nil
- Test 4: ImageToRawBitmap(1-bit image) returns byte slice of length ceil(width*height/8)
- Test 5: PrtQutieDriver.Connect() returns ErrNoDevice (not a panic) when no serial port matches the VID/PID
</behavior>
<action>
Create `internal/printer/driver.go`:
```go
package printer
import (
"errors"
"image"
"math"
)
// PrinterDriver is the interface all label printer implementations must satisfy.
// The PRT Qutie driver, mock driver, and any future printer use this interface.
type PrinterDriver interface {
// Connect opens the connection to the printer. Must be called before Print.
Connect() error
// Print sends a rendered label bitmap to the printer.
// bitmap is raw 1-bit packed row-major data from ImageToRawBitmap.
Print(bitmap []byte, width, height int) error
// Disconnect closes the connection.
Disconnect() error
}
var (
ErrNoDevice = errors.New("printer: no device found matching VID/PID")
ErrNotConnected = errors.New("printer: not connected")
ErrEmptyBitmap = errors.New("printer: bitmap is empty")
)
// ImageToRawBitmap converts an image.Image to 1-bit packed row-major bitmap.
// White pixels → 0, dark pixels → 1. Returns bytes and (width, height).
func ImageToRawBitmap(img image.Image) ([]byte, int, int) {
b := img.Bounds()
w, h := b.Max.X-b.Min.X, b.Max.Y-b.Min.Y
rowBytes := int(math.Ceil(float64(w) / 8.0))
out := make([]byte, rowBytes*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bv, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
// Luminance: pixel is dark if average < 50% of max
lum := (r + g + bv) / 3
if lum < 0x7fff {
byteIdx := y*rowBytes + x/8
out[byteIdx] |= 1 << (7 - uint(x%8))
}
}
}
return out, w, h
}
```
Create `internal/printer/mock_driver.go`:
```go
package printer
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"time"
)
// MockDriver implements PrinterDriver for testing and development.
// Print() saves a PNG to /tmp/hwlab-label-TIMESTAMP.png and logs to stdout.
type MockDriver struct {
connected bool
SaveDir string // defaults to /tmp
}
func NewMockDriver() *MockDriver {
return &MockDriver{SaveDir: "/tmp"}
}
func (m *MockDriver) Connect() error { m.connected = true; return nil }
func (m *MockDriver) Disconnect() error { m.connected = false; return nil }
func (m *MockDriver) Print(bitmap []byte, width, height int) error {
if len(bitmap) == 0 { return ErrEmptyBitmap }
// Reconstruct image from 1-bit bitmap for visual inspection
img := image.NewGray(image.Rect(0, 0, width, height))
rowBytes := (width + 7) / 8
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
byteIdx := y*rowBytes + x/8
bit := (bitmap[byteIdx] >> (7 - uint(x%8))) & 1
if bit == 1 {
img.SetGray(x, y, color.Gray{Y: 0})
} else {
img.SetGray(x, y, color.Gray{Y: 255})
}
}
}
path := fmt.Sprintf("%s/hwlab-label-%d.png", m.SaveDir, time.Now().UnixMilli())
f, err := os.Create(path)
if err != nil { return err }
defer f.Close()
if err := png.Encode(f, img); err != nil { return err }
fmt.Printf("[MockDriver] label saved → %s (%dx%d)\n", path, width, height)
return nil
}
```
Create `internal/printer/prt_qutie.go`:
```go
package printer
import (
"go.bug.st/serial"
)
// PrtQutieDriver implements PrinterDriver for the PRT Qutie thermal printer.
// Protocol: UNKNOWN — reverse engineering required after hardware arrival 2026-04-13.
// TODO(hardware): Replace the stub Print() with real ESC/POS or PRT Qutie commands
// after capturing USB traffic with Wireshark on hardware arrival day.
type PrtQutieDriver struct {
portPath string // resolved at Connect() time, not stored across sessions
port serial.Port
baudRate int
}
// NewPrtQutieDriver creates a driver. portPath is resolved by USBManager.
// Pass "" to have Connect() auto-resolve via VID/PID enumeration.
func NewPrtQutieDriver(baudRate int) *PrtQutieDriver {
if baudRate == 0 { baudRate = 9600 }
return &PrtQutieDriver{baudRate: baudRate}
}
func (d *PrtQutieDriver) Connect() error {
// TODO(hardware): resolve portPath via USB Manager or serial.GetPortsList()
// For now, return ErrNoDevice to make the stub safe to call in tests
return ErrNoDevice
}
func (d *PrtQutieDriver) Print(bitmap []byte, width, height int) error {
if d.port == nil { return ErrNotConnected }
// TODO(hardware): implement PRT Qutie print protocol
// Likely ESC/POS or proprietary binary command sequence
// Reference: https://atctwo.net/posts/2024/07/16/thermal-printer.html
_ = bitmap; _ = width; _ = height
return nil
}
func (d *PrtQutieDriver) Disconnect() error {
if d.port != nil {
err := d.port.Close()
d.port = nil
return err
}
return nil
}
```
Write `internal/printer/driver_test.go` covering the 5 behavior tests. Use a temp dir (t.TempDir()) instead of /tmp for Test 1 by setting MockDriver.SaveDir.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/printer/... -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All 5 tests pass. PrtQutieDriver.Connect() returns ErrNoDevice (stub). MockDriver.Print() saves PNG and returns nil for valid input.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: LabelHandler + USBEventsHandler + router wiring</name>
<files>internal/api/handlers/label.go, internal/api/handlers/label_test.go, internal/api/handlers/usb_events.go, internal/api/router.go</files>
<behavior>
- Test 1: POST /api/labels/42/print with mock NetBox returning a Device and mock printer calls Print() once, returns 200 JSON {"status":"printed"}
- Test 2: POST /api/labels/99/print where mock NetBox returns ErrNotFound returns 404
- Test 3: POST /api/labels/42/print where mock printer returns error returns 500 with {"error":"..."}
- Test 4: GET /api/usb/events sets Content-Type: text/event-stream, writes "data: {...}\n\n" on DeviceEvent receive
- Test 5: GET /api/usb/events closes cleanly (no goroutine leak) when client disconnects (r.Context() cancels)
</behavior>
<action>
Create `internal/api/handlers/label.go`:
```go
package handlers
import (
"context"
"image"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.georgsen.dk/hwlab/internal/labels"
"git.georgsen.dk/hwlab/internal/netbox"
"git.georgsen.dk/hwlab/internal/printer"
)
// LabelNetBoxClient is the narrow interface LabelHandler needs.
type LabelNetBoxClient interface {
GetDevice(ctx context.Context, id int) (*netbox.Device, error)
}
// LabelPrinter is the narrow interface over printer.PrinterDriver.
type LabelPrinter interface {
Print(bitmap []byte, width, height int) error
}
// LabelHandler handles POST /api/labels/:deviceID/print.
type LabelHandler struct {
nb LabelNetBoxClient
printer LabelPrinter
}
func NewLabelHandler(nb LabelNetBoxClient, p LabelPrinter) *LabelHandler {
return &LabelHandler{nb: nb, printer: p}
}
func (h *LabelHandler) PrintLabel(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "deviceID")
id, err := strconv.Atoi(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
return
}
device, err := h.nb.GetDevice(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
var img image.Image
if labels.IsCableDevice(*device) {
// Parse cable fields from TestData JSON; use SpecLine fallback if empty
d := labels.CableLabelData{
HWID: device.CustomFields.HWID,
Name: device.Name,
TestDate: device.CustomFields.TestDate,
// USBVersion/MaxSpeedGbps/MaxWatts: parsed from TestData JSON in Phase 5
// For now use defaults so label renders without error
USBVersion: "Unknown",
}
img, err = labels.RenderCable(d)
} else {
d := labels.LabelData{
HWID: device.CustomFields.HWID,
Name: device.Name,
SpecLine: device.CustomFields.AINotes,
}
img, err = labels.RenderStandard(d)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()})
return
}
bitmap, width, height := printer.ImageToRawBitmap(img)
if err := h.printer.Print(bitmap, width, height); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "print failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "printed"})
}
```
Create `internal/api/handlers/usb_events.go`:
```go
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"git.georgsen.dk/hwlab/internal/usb"
)
// USBEventSource is the narrow interface over usb.Manager.
type USBEventSource interface {
Events() <-chan usb.DeviceEvent
}
// USBEventsHandler handles GET /api/usb/events — SSE stream of USB device events.
type USBEventsHandler struct {
manager USBEventSource
}
func NewUSBEventsHandler(m USBEventSource) *USBEventsHandler {
return &USBEventsHandler{manager: m}
}
func (h *USBEventsHandler) ServeEvents(w http.ResponseWriter, r *http.Request) {
// SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// Send initial keepalive comment
fmt.Fprintf(w, ": connected\n\n")
flusher.Flush()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case evt, ok := <-h.manager.Events():
if !ok { return }
data, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-ticker.C:
// Keepalive comment to prevent proxy timeout
fmt.Fprintf(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
```
Update `internal/api/router.go` — add two parameters:
```go
func NewRouter(
staticFiles fs.FS,
intakeHandler http.Handler,
inventoryHandler *handlers.InventoryHandler,
labelHandler *handlers.LabelHandler, // NEW
usbEventsHandler *handlers.USBEventsHandler, // NEW
) http.Handler {
// existing setup...
r.Route("/api", func(r chi.Router) {
r.Get("/health", handlers.Health)
r.Post("/intake", intakeHandler.ServeHTTP)
r.Get("/inventory", inventoryHandler.ListInventory)
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel) // NEW
r.Get("/usb/events", usbEventsHandler.ServeEvents) // NEW
})
r.Handle("/*", spaHandler{staticFS: staticFiles})
return r
}
```
Also update `cmd/hwlab/main.go` to construct USBManager, MockDriver (until hardware arrives), LabelHandler, USBEventsHandler, and pass them to NewRouter. The USB Manager must be started via `go usbManager.Start(ctx)` with the same context used for the WAQ worker.
Write `internal/api/handlers/label_test.go` covering tests 1-5. For tests 4-5, use `httptest.NewRecorder()` with a context that gets cancelled to verify SSE cleanup. Use a buffered channel as the mock event source.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestLabel|TestUSBEvents" 2>&amp;1 | tail -25</automated>
</verify>
<done>All handler tests pass. `go build ./cmd/hwlab/...` succeeds with updated NewRouter signature and main.go wiring.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client → POST /api/labels/:deviceID/print | Unauthenticated call can trigger printer hardware |
| SSE stream → browser client | Server pushes device state; no user input flows back |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-08 | Spoofing | POST /api/labels | accept | Solo-operator homelab on LAN; no auth on HWLab backend per current design (see PITFALLS.md security note — bearer token deferred to post-MVP) |
| T-04-09 | Denial of Service | print endpoint | mitigate | Rate limit print calls to 1/second using chi middleware or a simple lastPrintTime check — prevents runaway label waste |
| T-04-10 | Information Disclosure | SSE stream | accept | Device VID/PID and names are not sensitive; stream is LAN-only |
| T-04-11 | Denial of Service | SSE goroutine leak | mitigate | Handler returns on r.Context().Done(); verified by Test 5 |
</threat_model>
<verification>
1. `go test ./internal/printer/... ./internal/api/handlers/... -v` — all tests pass
2. `go build ./cmd/hwlab/...` — compiles with updated router signature
3. Print endpoint test: POST /api/labels/1/print with mock returns 200 {"status":"printed"}
4. SSE test: GET /api/usb/events delivers "data: {...}" on event, exits on context cancel
</verification>
<success_criteria>
- PrinterDriver interface with Connect/Print/Disconnect
- MockDriver saves PNG to /tmp, usable without hardware
- PrtQutieDriver stub returns ErrNoDevice — safe to call, clearly marked TODO(hardware)
- POST /api/labels/:deviceID/print works end-to-end with mock driver
- GET /api/usb/events streams SSE with keepalive and clean goroutine teardown
- router.go updated, main.go starts USB Manager and uses MockDriver
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md`
</output>