--- phase: 04-usb-manager-label-printing plan: "03" subsystem: printer tags: [go, printer, serial, sse, http, tdd] dependency_graph: requires: - internal/usb/manager.go (DeviceEvent, Manager.Events) - internal/labels/renderer.go (RenderStandard, RenderCable, IsCableDevice) - internal/netbox/types.go (Device, CustomFields) provides: - internal/printer package (PrinterDriver, MockDriver, PrtQutieDriver) - POST /api/labels/:deviceID/print endpoint - GET /api/usb/events SSE endpoint affects: - cmd/hwlab/main.go (USB Manager start, MockDriver, new handler wiring) - internal/api/router.go (two new parameters + routes) tech_stack: added: [] patterns: - "PrinterDriver interface: Connect/Print(bitmap,w,h)/Disconnect — driver-agnostic bitmap protocol" - "MockDriver: saves PNG to SaveDir for visual inspection without hardware" - "PrtQutieDriver: stub returns ErrNoDevice — safe before hardware arrives" - "SSE handler: select on r.Context().Done() for goroutine-leak-safe teardown" - "Rate limiter: sync.Mutex + lastPrintTime guards 1/s print cadence (T-04-09)" - "TDD: RED commit → GREEN commit for both tasks" key_files: created: - 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 modified: - internal/api/router.go - cmd/hwlab/main.go decisions: - "Print() signature is Print(bitmap []byte, width, height int) not Print(image.Image) — driver-agnostic; bitmap conversion happens in handler via ImageToRawBitmap()" - "Rate limit implemented inline (sync.Mutex + lastPrintTime) rather than chi middleware — simpler for single-driver use case, avoids middleware ordering complexity" - "MockDriver.SaveDir is exported so tests can inject t.TempDir() without /tmp pollution" - "PrtQutieDriver.Connect() returns ErrNoDevice stub — real port resolution deferred to hardware arrival day 2026-04-13" - "USBEventsHandler uses 30s ticker keepalive to prevent proxy timeouts on quiet periods" metrics: duration_minutes: 4 completed_date: "2026-04-10" tasks_completed: 2 tasks_total: 2 files_created: 7 files_modified: 2 --- # Phase 4 Plan 03: Printer Driver Interface + HTTP Endpoints Summary **One-liner:** PrinterDriver interface with MockDriver (PNG-to-/tmp) and PrtQutieDriver stub, plus POST /api/labels/:id/print (rate-limited) and GET /api/usb/events (SSE with goroutine-safe teardown). ## What Was Built ### internal/printer/driver.go `PrinterDriver` interface with `Connect() error`, `Print(bitmap []byte, width, height int) error`, `Disconnect() error`. `ImageToRawBitmap(img image.Image) ([]byte, int, int)` converts `image.Image` to 1-bit packed row-major bitmap (dark pixels → 1). Sentinel errors: `ErrNoDevice`, `ErrNotConnected`, `ErrEmptyBitmap`. ### internal/printer/mock_driver.go `MockDriver` implements `PrinterDriver`. `Print()` reconstructs a grayscale PNG from the 1-bit bitmap and saves it to `SaveDir` (default `/tmp`) as `hwlab-label-.png`. Logs path to stdout. No hardware required — default driver until PRT Qutie arrives. ### internal/printer/prt_qutie.go `PrtQutieDriver` implements `PrinterDriver` using `go.bug.st/serial`. `Connect()` returns `ErrNoDevice` (stub) — real port resolution deferred to hardware characterization day 2026-04-13. Print protocol unknown; `TODO(hardware)` comments reference Wireshark capture approach. ### internal/api/handlers/label.go `LabelHandler` handles `POST /api/labels/:deviceID/print`. Fetches device from NetBox, routes to `labels.RenderCable` or `labels.RenderStandard` based on `IsCableDevice()`, converts to bitmap via `printer.ImageToRawBitmap`, calls `LabelPrinter.Print()`. Rate-limited to 1 print/second via `sync.Mutex + lastPrintTime` (T-04-09 mitigation). ### internal/api/handlers/usb_events.go `USBEventsHandler` handles `GET /api/usb/events`. Streams `usb.DeviceEvent` values as Server-Sent Events. 30-second keepalive ticker prevents proxy timeouts. Returns on `r.Context().Done()` — no goroutine leak (T-04-11 mitigation verified by Test 5). ### internal/api/router.go Updated `NewRouter` signature to accept `*handlers.LabelHandler` and `*handlers.USBEventsHandler`. Routes: `POST /api/labels/{deviceID}/print` and `GET /api/usb/events`. ### cmd/hwlab/main.go USB Manager created (`usb.NewManager(2*time.Second)`), started via `go usbManager.Start(ctx)`, stopped via `defer usbManager.Stop()`. `printer.NewMockDriver()` constructed and connected. `LabelHandler` and `USBEventsHandler` constructed and passed to `NewRouter`. ## Commits | Task | Commit | Description | |------|---------|-------------| | Task 1 RED | b223b54 | Failing printer driver tests (TDD RED) | | Task 1 GREEN | 1156eff | PrinterDriver interface, MockDriver, PrtQutieDriver stub | | Task 2 RED | dd381ee | Failing handler tests for label print and USB SSE (TDD RED) | | Task 2 GREEN | 9f57cbd | LabelHandler, USBEventsHandler, router wiring, main.go init | ## Test Results ``` === RUN TestMockDriverPrintSavesPNG PASS === RUN TestMockDriverPrintNilBitmap PASS === RUN TestMockDriverConnectDisconnect PASS === RUN TestImageToRawBitmapLength PASS === RUN TestPrtQutieConnectReturnsErrNoDevice PASS === RUN TestMockDriverSaveDirHonoured PASS === RUN TestLabelPrintSuccess PASS === RUN TestLabelPrintDeviceNotFound PASS === RUN TestLabelPrintPrinterError PASS === RUN TestUSBEventsSSEContentType PASS === RUN TestUSBEventsNoGoroutineLeak PASS PASS — go build ./... PASS ``` ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 2 - Missing Critical] Rate limiting on print endpoint (T-04-09)** - **Found during:** Task 2 — threat model review - **Issue:** Threat register marks T-04-09 (DoS via runaway print calls / label waste) as `mitigate`. Plan code snippet had no rate limiting. - **Fix:** Added `sync.Mutex + lastPrintTime + printCooldown(1s)` to `LabelHandler`. Returns HTTP 429 when violated. - **Files modified:** internal/api/handlers/label.go - **Commit:** 9f57cbd ## Known Stubs | File | Line | Item | Reason | |------|------|------|--------| | internal/printer/prt_qutie.go | 35 | `Connect()` returns `ErrNoDevice` | Real VID/PID port enumeration deferred to hardware arrival 2026-04-13 | | internal/printer/prt_qutie.go | 43–50 | `Print()` is a no-op | PRT Qutie protocol unknown — requires Wireshark capture on hardware day | | internal/api/handlers/label.go | 92–99 | `USBVersion: "Unknown"` in cable label | Cable field parsing from `TestData` JSON deferred to Phase 5 | These stubs do not prevent the plan's goal from being achieved: MockDriver renders and saves real PNG labels; the PrtQutieDriver stub is intentionally non-functional until hardware arrives. ## Threat Flags None — no new network endpoints beyond the two planned (`POST /api/labels/:id/print`, `GET /api/usb/events`). Both are LAN-only per project constraints. ## Self-Check: PASSED - internal/printer/driver.go: FOUND - internal/printer/mock_driver.go: FOUND - internal/printer/prt_qutie.go: FOUND - internal/printer/driver_test.go: FOUND - internal/api/handlers/label.go: FOUND - internal/api/handlers/label_test.go: FOUND - internal/api/handlers/usb_events.go: FOUND - internal/api/router.go: FOUND (modified) - cmd/hwlab/main.go: FOUND (modified) - Commit b223b54: FOUND - Commit 1156eff: FOUND - Commit dd381ee: FOUND - Commit 9f57cbd: FOUND