--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md @internal/api/router.go @internal/netbox/types.go @internal/api/handlers/inventory.go 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 Task 1: PrinterDriver interface, MockDriver, PrtQutieDriver stub internal/printer/driver.go, internal/printer/mock_driver.go, internal/printer/prt_qutie.go, internal/printer/driver_test.go - 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 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. cd /home/mikkel/homelabby && go test ./internal/printer/... -v 2>&1 | tail -20 All 5 tests pass. PrtQutieDriver.Connect() returns ErrNoDevice (stub). MockDriver.Print() saves PNG and returns nil for valid input. Task 2: LabelHandler + USBEventsHandler + router wiring internal/api/handlers/label.go, internal/api/handlers/label_test.go, internal/api/handlers/usb_events.go, internal/api/router.go - 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) 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. cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestLabel|TestUSBEvents" 2>&1 | tail -25 All handler tests pass. `go build ./cmd/hwlab/...` succeeds with updated NewRouter signature and main.go wiring. ## 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 | 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 - 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 After completion, create `.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md`