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>
20 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-usb-manager-label-printing | 03 | execute | 2 |
|
|
true |
|
|
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.goFrom 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
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.
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> |
<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>