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>
558 lines
20 KiB
Markdown
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>&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>&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>
|