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

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-usb-manager-label-printing 03 execute 2
04-01
04-02
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
true
LBL-04
USB-04
truths artifacts key_links
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
path provides exports
internal/printer/driver.go PrinterDriver interface, ImageToRawBitmap() helper
PrinterDriver
ImageToRawBitmap
path provides exports
internal/printer/mock_driver.go MockDriver implementing PrinterDriver — saves PNG to /tmp
MockDriver
NewMockDriver
path provides exports
internal/printer/prt_qutie.go PrtQutieDriver implementing PrinterDriver via serial port
PrtQutieDriver
NewPrtQutieDriver
path provides exports
internal/api/handlers/label.go LabelHandler: POST /api/labels/:deviceID/print
LabelHandler
NewLabelHandler
path provides exports
internal/api/handlers/usb_events.go USBEventsHandler: GET /api/usb/events SSE stream
USBEventsHandler
NewUSBEventsHandler
path provides
internal/api/router.go Updated router wiring the two new endpoints
from to via pattern
internal/api/handlers/label.go internal/labels/renderer.go calls labels.RenderStandard or labels.RenderCable based on IsCableDevice() labels.Render
from to via pattern
internal/api/handlers/label.go internal/printer/driver.go PrinterDriver.Print(imageToRawBitmap(img)) driver.Print
from to via pattern
internal/api/handlers/usb_events.go internal/usb/manager.go reads from Manager.Events() channel and writes SSE lines 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

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

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

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

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:

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

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

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:

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

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:

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.

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

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