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>
This commit is contained in:
parent
6a8a4658a7
commit
77bf4ebfd6
6 changed files with 1836 additions and 4 deletions
|
|
@ -61,7 +61,7 @@ Plans:
|
|||
|
||||
### Phase 3: Dashboard & Intake UI
|
||||
**Goal**: Users can browse their full inventory, run intake for new items, and view item detail — all through the React SPA served by the Go binary
|
||||
**Depends on**: Phase 2
|
||||
**Depends on**: Phase 3
|
||||
**Requirements**: UI-01, UI-02, UI-03, UI-04, UI-05, UI-06, PWA-01, PWA-02, PWA-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Inventory dashboard loads with grid/list toggle, item cards showing photo, HW ID, status, and key specs
|
||||
|
|
@ -88,8 +88,14 @@ Plans:
|
|||
3. A QR label with HW ID, item name, key spec line, and QR code is printed for any item from the dashboard quick actions
|
||||
4. Cable-specific label template correctly shows USB version, speed, wattage, and test date
|
||||
5. Label printing completes as the final step of the AI intake workflow without leaving the intake screen
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Plans**: 5 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 04-01-PLAN.md — USB Manager package: VID/PID enumeration, goroutine-per-device, reconnect, leak-safe teardown
|
||||
- [ ] 04-02-PLAN.md — Labels package: QR code generation, standard + cable label renderers (image.Image output)
|
||||
- [ ] 04-03-PLAN.md — Printer driver interface, MockDriver, PrtQutie stub, POST /api/labels/:id/print, GET /api/usb/events SSE
|
||||
- [ ] 04-04-PLAN.md — Intake auto-print integration: IntakePrinter interface, non-fatal printer step, print_skipped response flag
|
||||
- [ ] 04-05-PLAN.md — Frontend: useUSBEvents hook, USBStatusBar component, Print Label button on dashboard
|
||||
|
||||
### Phase 5: Cable Test Integration
|
||||
**Goal**: Any cable can be tested, its results written to NetBox, and a label printed in a rapid test-verify-print workflow without leaving the Cable Test Station view
|
||||
|
|
@ -137,7 +143,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
|||
| 1. Foundation | 5/5 | Complete | 2026-04-10 |
|
||||
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
|
||||
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
|
||||
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
|
||||
| 4. USB Manager & Label Printing | 0/5 | Not started | - |
|
||||
| 5. Cable Test Integration | 0/TBD | Not started | - |
|
||||
| 6. Lab Advisor | 0/TBD | Not started | - |
|
||||
| 7. Research Agent & Search | 0/TBD | Not started | - |
|
||||
|
|
|
|||
322
.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md
Normal file
322
.planning/phases/04-usb-manager-label-printing/04-01-PLAN.md
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
---
|
||||
phase: 04-usb-manager-label-printing
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/usb/manager.go
|
||||
- internal/usb/manager_test.go
|
||||
- internal/usb/device.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
autonomous: true
|
||||
requirements: [USB-01, USB-02, USB-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "USB Manager discovers devices by VID/PID, never by path"
|
||||
- "Each connected device runs in its own goroutine with command and event channels"
|
||||
- "Unplugging a device causes the owning goroutine to exit cleanly without leaking"
|
||||
- "Replugging the same device (same VID/PID) reconnects automatically without operator action"
|
||||
- "Goroutine count is stable across 5 consecutive unplug/replug cycles"
|
||||
artifacts:
|
||||
- path: internal/usb/device.go
|
||||
provides: "DeviceSpec, DeviceState, DeviceEvent, Command types + KnownDevices registry"
|
||||
exports: [DeviceSpec, DeviceState, DeviceEvent, Command, KnownDevices]
|
||||
- path: internal/usb/manager.go
|
||||
provides: "USBManager with goroutine-per-device, VID/PID poll loop, reconnect"
|
||||
exports: [Manager, NewManager, Manager.Start, Manager.Stop, Manager.Events]
|
||||
- path: internal/usb/manager_test.go
|
||||
provides: "Unit tests covering goroutine count stability and reconnect logic with mock serial port"
|
||||
min_lines: 80
|
||||
key_links:
|
||||
- from: internal/usb/manager.go
|
||||
to: go.bug.st/serial
|
||||
via: "serial.GetPortsList() + port.Open() per VID/PID match"
|
||||
pattern: "serial\\.GetPortsList"
|
||||
- from: internal/usb/manager.go
|
||||
to: internal/usb/device.go
|
||||
via: "KnownDevices lookup keyed by VID:PID string"
|
||||
pattern: "KnownDevices"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the `internal/usb` package: USB Manager with goroutine-per-device model, VID/PID-based enumeration, reconnect handling, and leak-safe goroutine teardown.
|
||||
|
||||
Purpose: Foundation for all USB peripheral interaction in Phase 4 and Phase 5. The PRT Qutie and future Treedix testers must be enumerated by stable VID/PID identity, not ephemeral macOS paths. Goroutine leak prevention is critical for a long-running daemon.
|
||||
|
||||
Output: `internal/usb` package with Manager, DeviceSpec registry, and passing tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/research/PITFALLS.md
|
||||
|
||||
# Existing code this plan extends
|
||||
@internal/api/router.go
|
||||
@internal/config/config.go
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. No codebase exploration required. -->
|
||||
|
||||
From go.mod — module path:
|
||||
```
|
||||
module git.georgsen.dk/hwlab
|
||||
go 1.23.0
|
||||
```
|
||||
|
||||
From internal/config/config.go (viper-based, add USB section):
|
||||
The existing config pattern uses `viper.GetString("key")`. New USB config keys:
|
||||
- `usb.poll_interval_ms` (default 2000)
|
||||
- `usb.prt_qutie_vid` (default "0525")
|
||||
- `usb.prt_qutie_pid` (default "a4a7") <!-- placeholder — update after hardware arrives -->
|
||||
|
||||
go.bug.st/serial API (to be added as dependency):
|
||||
```go
|
||||
import "go.bug.st/serial"
|
||||
|
||||
// Enumerate all current serial ports
|
||||
ports, err := serial.GetPortsList() // returns []string of /dev/cu.* paths
|
||||
|
||||
// Open a port
|
||||
mode := &serial.Mode{BaudRate: 9600}
|
||||
port, err := serial.Open(path, mode)
|
||||
|
||||
// Read (blocks until data or close)
|
||||
n, err := port.Read(buf)
|
||||
|
||||
// Unblock a blocked Read from another goroutine
|
||||
port.Close()
|
||||
|
||||
// Get port details (VID/PID) — macOS requires ioreg or system_profiler
|
||||
// go.bug.st/serial does NOT expose VID/PID directly on macOS.
|
||||
// Use: serial.GetDetailedPortsList() on platforms that support it,
|
||||
// OR shell out to: system_profiler SPUSBDataType -json
|
||||
// See task action for the chosen approach.
|
||||
```
|
||||
|
||||
Important pitfall (from PITFALLS.md Pitfall 1 + Pitfall 2):
|
||||
- NEVER store device paths long-term — re-resolve on every reconnect
|
||||
- Use context.Context cancellation + done channel before port.Close() to avoid goroutine leak
|
||||
- Test with runtime.NumGoroutine() before/after replug cycles
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Device types, KnownDevices registry, and VID/PID enumeration helper</name>
|
||||
<files>internal/usb/device.go, internal/usb/device_test.go, go.mod, go.sum</files>
|
||||
<behavior>
|
||||
- Test 1: KnownDevices["0525:a4a7"] returns a DeviceSpec with Role == RolePrinter
|
||||
- Test 2: enumerateByVIDPID with a mock port list returns only ports whose ioreg output contains the target VID/PID
|
||||
- Test 3: DeviceSpec.String() returns "VID:PID (Name)" format for logging
|
||||
- Test 4: ParseVIDPID("0525:a4a7") returns vid="0525", pid="a4a7", nil error
|
||||
- Test 5: ParseVIDPID("badvalue") returns error
|
||||
</behavior>
|
||||
<action>
|
||||
Run: `go get go.bug.st/serial@latest` to add dependency.
|
||||
|
||||
Create `internal/usb/device.go`:
|
||||
|
||||
```go
|
||||
package usb
|
||||
|
||||
// DeviceRole classifies what a USB device is used for in HWLab.
|
||||
type DeviceRole int
|
||||
|
||||
const (
|
||||
RolePrinter DeviceRole = iota
|
||||
RoleCableTester // reserved for Phase 5
|
||||
RoleUnknown
|
||||
)
|
||||
|
||||
// DeviceSpec describes a known USB peripheral by VID and PID.
|
||||
type DeviceSpec struct {
|
||||
VID string // 4-hex-digit vendor ID, e.g. "0525"
|
||||
PID string // 4-hex-digit product ID, e.g. "a4a7"
|
||||
Name string // human-readable label for logs
|
||||
Role DeviceRole
|
||||
BaudRate int // serial baud rate; 0 means use 9600 default
|
||||
}
|
||||
|
||||
func (s DeviceSpec) VIDPID() string { return s.VID + ":" + s.PID }
|
||||
func (s DeviceSpec) String() string { return s.VIDPID() + " (" + s.Name + ")" }
|
||||
|
||||
// KnownDevices maps "VID:PID" to DeviceSpec for all peripherals HWLab manages.
|
||||
// Update VID/PID values after hardware characterization on 2026-04-13.
|
||||
var KnownDevices = map[string]DeviceSpec{
|
||||
"0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600},
|
||||
// Phase 5: Treedix testers will be added here after characterization
|
||||
}
|
||||
|
||||
// DeviceState represents the current connection status of a managed device.
|
||||
type DeviceState int
|
||||
|
||||
const (
|
||||
StateDisconnected DeviceState = iota
|
||||
StateConnected
|
||||
)
|
||||
|
||||
// DeviceEvent is emitted on the Manager.Events() channel for any state change.
|
||||
type DeviceEvent struct {
|
||||
VIDPID string
|
||||
Spec DeviceSpec
|
||||
State DeviceState
|
||||
}
|
||||
|
||||
// Command is sent to a device goroutine via its command channel.
|
||||
type Command struct {
|
||||
Type CommandType
|
||||
Payload []byte
|
||||
Reply chan<- error // caller closes or receives result
|
||||
}
|
||||
|
||||
type CommandType int
|
||||
|
||||
const (
|
||||
CmdWrite CommandType = iota
|
||||
CmdClose
|
||||
)
|
||||
```
|
||||
|
||||
Create `internal/usb/enumerate.go` with the VID/PID enumeration logic:
|
||||
- `enumerateConnected() (map[string]string, error)` — returns map[VIDPID]portPath
|
||||
- On macOS: shell out to `system_profiler SPUSBDataType -json` and parse the JSON tree for "vendor_id" and "product_id" fields, then cross-reference with `serial.GetPortsList()` to find the matching tty/cu path
|
||||
- Return only devices whose VID:PID appears in KnownDevices
|
||||
- If `system_profiler` is unavailable (Linux CI): fall back to scanning `/sys/bus/usb/devices/` or returning empty map with no error (graceful degradation)
|
||||
- The function must be testable: accept an optional override func for the shell command output (dependency injection via package-level var `sysProfilerCmd func() ([]byte, error)`)
|
||||
|
||||
Write `internal/usb/device_test.go` covering the 5 behavior tests listed above. For enumerateByVIDPID tests, override `sysProfilerCmd` with a fixture returning known JSON before calling the function.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/usb/... -run "TestDeviceSpec|TestParseVIDPID|TestEnumerate" -v 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>All 5 behavior tests pass. `go build ./...` succeeds. go.mod includes go.bug.st/serial.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: USB Manager — goroutine-per-device, poll loop, reconnect, leak-safe teardown</name>
|
||||
<files>internal/usb/manager.go, internal/usb/manager_test.go</files>
|
||||
<behavior>
|
||||
- Test 1: Manager.Start() with a mock enumeration returning one device spawns exactly 1 device goroutine
|
||||
- Test 2: When mock enumeration transitions device from present→absent→present, a DeviceEvent{Disconnected} then DeviceEvent{Connected} is emitted on Events() channel
|
||||
- Test 3: Manager.Stop() causes all device goroutines to exit; runtime.NumGoroutine() returns to baseline within 500ms
|
||||
- Test 4: Manager.Send(vidpid, cmd) returns ErrDeviceNotConnected when the device is absent
|
||||
- Test 5: Goroutine count is stable (±2) across 5 simulated unplug/replug cycles using the mock enumerator
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/usb/manager.go`:
|
||||
|
||||
```go
|
||||
package usb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns all USB device goroutines and emits DeviceEvents to subscribers.
|
||||
type Manager struct {
|
||||
pollInterval time.Duration
|
||||
events chan DeviceEvent
|
||||
cmdChans map[string]chan Command // keyed by VID:PID
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
// enumerateFunc is injectable for tests; defaults to enumerateConnected
|
||||
enumerateFunc func() (map[string]string, error)
|
||||
}
|
||||
|
||||
func NewManager(pollInterval time.Duration) *Manager { ... }
|
||||
|
||||
// Start begins the poll loop. Call Stop to shut down cleanly.
|
||||
func (m *Manager) Start(ctx context.Context) { ... }
|
||||
|
||||
// Stop cancels all device goroutines and waits for them to exit.
|
||||
func (m *Manager) Stop() { ... }
|
||||
|
||||
// Events returns the read-only channel of DeviceEvent notifications.
|
||||
func (m *Manager) Events() <-chan DeviceEvent { return m.events }
|
||||
|
||||
// Send delivers a Command to the named device. Returns ErrDeviceNotConnected if absent.
|
||||
func (m *Manager) Send(vidpid string, cmd Command) error { ... }
|
||||
```
|
||||
|
||||
Poll loop behavior:
|
||||
1. Every `pollInterval`, call `enumerateFunc()` to get map[VIDPID]portPath
|
||||
2. For each VID:PID in KnownDevices: compare to previous snapshot
|
||||
3. Newly present → spawn `deviceLoop(ctx, spec, portPath, cmdChan)` goroutine, emit Connected event
|
||||
4. Newly absent → send CmdClose to cmdChan, wait for goroutine to exit via done channel, emit Disconnected event
|
||||
5. Update snapshot
|
||||
|
||||
Device loop (`deviceLoop` private function):
|
||||
1. Open serial port with the given path
|
||||
2. Launch inner read goroutine that reads until context cancelled or port closed
|
||||
3. Main loop: select on cmdChan and context.Done()
|
||||
4. On CmdWrite: write payload to port
|
||||
5. On CmdClose or context cancellation: cancel inner context, call port.Close(), drain any pending events, return
|
||||
6. Defer wg.Done()
|
||||
|
||||
Goroutine leak prevention (per PITFALLS.md Pitfall 2):
|
||||
- Each deviceLoop receives a child context derived from the Manager's root context
|
||||
- Before port.Close(), cancel the child context
|
||||
- Inner read goroutine selects on context.Done(); exits if context is done before next Read returns
|
||||
- Use a `done` channel (close it when deviceLoop exits) so the poll loop can wait with a timeout before marking the device as gone from cmdChans
|
||||
|
||||
Test setup: inject a mock `enumerateFunc` that returns predefined snapshots per call number. Use `runtime.NumGoroutine()` before and after to verify leak-free teardown.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/usb/... -v -race -timeout 30s 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<done>All tests pass with -race. No goroutine leak detected. `go vet ./internal/usb/...` clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| host OS → serial port | USB device data enters as raw bytes from a physical peripheral |
|
||||
| serial port → Manager | Device goroutine reads bytes; poisoned data could cause parse panics |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-01 | Tampering | deviceLoop read buffer | mitigate | Cap read buffer at 4096 bytes; validate length before forwarding to command handler |
|
||||
| T-04-02 | Denial of Service | poll loop | accept | Poll interval is configurable (default 2s); single-user homelab, no adversarial hotplug expected |
|
||||
| T-04-03 | Information Disclosure | system_profiler output | accept | USB device metadata is not sensitive; output is local-only, never logged to external sink |
|
||||
| T-04-04 | Elevation of Privilege | serial port open | accept | /dev/cu.* permissions on macOS require the user running the process to be in the `dialout` group or have standard user access; HWLab runs as the operator user |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `go test ./internal/usb/... -race -v` — all tests pass, race detector clean
|
||||
2. `go build ./...` — compiles with new dependency
|
||||
3. `go vet ./internal/usb/...` — no issues
|
||||
4. Goroutine stability: TestGoroutineStability passes (count ±2 across 5 replug cycles)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `internal/usb` package exists with Manager, DeviceSpec, DeviceEvent, Command types
|
||||
- KnownDevices registry includes PRT Qutie VID:PID placeholder
|
||||
- VID/PID enumeration uses system_profiler on macOS, never stores paths between polls
|
||||
- goroutine-per-device with context+done-channel teardown
|
||||
- All tests pass with -race flag
|
||||
- No goroutine count growth in stability test
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md`
|
||||
</output>
|
||||
363
.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md
Normal file
363
.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
---
|
||||
phase: 04-usb-manager-label-printing
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/labels/renderer.go
|
||||
- internal/labels/renderer_test.go
|
||||
- internal/labels/cable.go
|
||||
- internal/labels/cable_test.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
autonomous: true
|
||||
requirements: [LBL-01, LBL-02, LBL-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "QR code encodes the full item URL (http://mac-mini.mg:8080/hw/HW-XXXXX)"
|
||||
- "Standard label bitmap contains QR code, HW ID, item name, and key spec line"
|
||||
- "Cable label bitmap contains USB version, max speed, max wattage, and test date"
|
||||
- "Both label types produce a valid Go image.NRGBA at 203 DPI suitable for the printer"
|
||||
artifacts:
|
||||
- path: internal/labels/renderer.go
|
||||
provides: "LabelData struct, RenderStandard() and RenderCable() producing image.Image"
|
||||
exports: [LabelData, CableLabelData, RenderStandard, RenderCable]
|
||||
- path: internal/labels/cable.go
|
||||
provides: "IsCableDevice() helper detecting cable records by tag/type"
|
||||
exports: [IsCableDevice]
|
||||
- path: internal/labels/renderer_test.go
|
||||
provides: "Unit tests: QR URL encoding, image dimensions, pixel non-blank check"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: internal/labels/renderer.go
|
||||
to: github.com/skip2/go-qrcode
|
||||
via: "qrcode.Encode(url, qrcode.Low, sizePixels)"
|
||||
pattern: "qrcode\\.Encode"
|
||||
- from: internal/labels/renderer.go
|
||||
to: golang.org/x/image/font/basicfont
|
||||
via: "basicfont.Face7x13 for text rendering"
|
||||
pattern: "basicfont"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the `internal/labels` package: QR code generation, label layout rendering (standard + cable templates), and cable-type detection helper.
|
||||
|
||||
Purpose: Produce label bitmaps that the printer driver can send to the PRT Qutie. This package is pure image processing — no USB or printer dependency — so it can be built and tested in Wave 1 in parallel with the USB Manager.
|
||||
|
||||
Output: `internal/labels` package with passing tests and deterministic bitmap output.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
|
||||
@internal/netbox/types.go
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. -->
|
||||
|
||||
From internal/netbox/types.go:
|
||||
```go
|
||||
type Device struct {
|
||||
ID int
|
||||
Name string
|
||||
AssetTag string // HW-XXXXX identifier
|
||||
CustomFields CustomFields
|
||||
Created time.Time
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
type CustomFields struct {
|
||||
HWID string
|
||||
CatalogStatus string
|
||||
TestDate string // ISO 8601 date string
|
||||
TestData string // JSON string (cable specs live here)
|
||||
AINotes string
|
||||
PhotoURLs []string
|
||||
}
|
||||
```
|
||||
|
||||
github.com/skip2/go-qrcode API:
|
||||
```go
|
||||
import "github.com/skip2/go-qrcode"
|
||||
|
||||
// Encode returns a PNG []byte at the given size in pixels
|
||||
png, err := qrcode.Encode(content, qrcode.Low, 128)
|
||||
|
||||
// Or generate a QR code object for image manipulation
|
||||
qr, err := qrcode.New(content, qrcode.Low)
|
||||
qr.DisableBorderPadding = true
|
||||
img := qr.Image(sizePixels) // returns image.Image
|
||||
```
|
||||
|
||||
golang.org/x/image/font/basicfont (already available transitively, add if needed):
|
||||
```go
|
||||
import (
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"image/draw"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// Draw text at position
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(color.Black),
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
|
||||
}
|
||||
d.DrawString("HW-00001")
|
||||
```
|
||||
|
||||
Label dimensions from CONTEXT.md:
|
||||
- Width: ~384px at 203 DPI (standard Brother QL / PRT Qutie 62mm tape)
|
||||
- Height: 120px (15mm at 203 DPI) — standard label height for compact items
|
||||
- Cable label: same width, 180px height to fit extra lines
|
||||
- QR block: 96x96px, positioned at left (x=8, y=12)
|
||||
- Text area: x=112, width=264px
|
||||
- Font: basicfont.Face7x13 (7px wide, 13px tall per char)
|
||||
|
||||
Base URL: "http://mac-mini.mg:8080/hw/" + hwID (from REQUIREMENTS.md LBL-01)
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: QR code generation + standard label renderer</name>
|
||||
<files>internal/labels/renderer.go, internal/labels/renderer_test.go, go.mod, go.sum</files>
|
||||
<behavior>
|
||||
- Test 1: RenderStandard with HWID="HW-00001" produces image.Image of width=384, height=120
|
||||
- Test 2: QR code in the rendered image is non-blank (top-left 96x96 region has mixed black/white pixels)
|
||||
- Test 3: QR code encodes "http://mac-mini.mg:8080/hw/HW-00001" (extract via go-qrcode round-trip)
|
||||
- Test 4: LabelData with empty Name uses "Unknown Item" as fallback — no panic
|
||||
- Test 5: RenderStandard produces a pure-white background (NRGBA canvas initialized to white)
|
||||
</behavior>
|
||||
<action>
|
||||
Run: `go get github.com/skip2/go-qrcode@latest` and `go get golang.org/x/image@latest`
|
||||
|
||||
Create `internal/labels/renderer.go`:
|
||||
|
||||
```go
|
||||
package labels
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
const (
|
||||
LabelWidth = 384 // pixels at 203 DPI ≈ 48mm usable
|
||||
LabelHeight = 120 // pixels ≈ 15mm
|
||||
QRSize = 96 // pixels
|
||||
QROffsetX = 8
|
||||
QROffsetY = 12
|
||||
TextOffsetX = 112
|
||||
BaseURL = "http://mac-mini.mg:8080/hw/"
|
||||
)
|
||||
|
||||
// LabelData holds the fields needed to render a standard item label.
|
||||
type LabelData struct {
|
||||
HWID string // e.g. "HW-00001"
|
||||
Name string // item name from NetBox
|
||||
SpecLine string // key spec summary, e.g. "Intel NUC i5 2019"
|
||||
}
|
||||
|
||||
// RenderStandard produces a 384x120 label bitmap (black on white) for a standard item.
|
||||
// Layout: QR code left (96x96 at x=8,y=12), text right (HW ID bold top, name middle, spec bottom).
|
||||
func RenderStandard(d LabelData) (image.Image, error) {
|
||||
if d.Name == "" {
|
||||
d.Name = "Unknown Item"
|
||||
}
|
||||
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight))
|
||||
// Fill white background
|
||||
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
||||
|
||||
// QR code
|
||||
qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qr.DisableBorderPadding = true
|
||||
qrImg := qr.Image(QRSize)
|
||||
qrDst := image.Rect(QROffsetX, QROffsetY, QROffsetX+QRSize, QROffsetY+QRSize)
|
||||
draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over)
|
||||
|
||||
// Text: HW ID (y=26), Name (y=60), SpecLine (y=92)
|
||||
drawText(img, TextOffsetX, 26, d.HWID)
|
||||
drawText(img, TextOffsetX, 60, truncate(d.Name, 30))
|
||||
drawText(img, TextOffsetX, 92, truncate(d.SpecLine, 30))
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func drawText(img draw.Image, x, y int, text string) {
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(color.Black),
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
```
|
||||
|
||||
Write `internal/labels/renderer_test.go` with the 5 behavior tests. For Test 3 (QR content), decode the QR from the rendered image using `github.com/tuotoo/barcodeReader` OR — simpler — use a round-trip: generate the QR independently with go-qrcode using the expected URL and compare the image dimensions/pixel counts rather than decoding. Accept: the test verifies that `qrcode.New(BaseURL+hwid, qrcode.Low)` produces consistent output by comparing two independently generated QR images at the same size.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -run "TestRenderStandard" -v 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>All 5 behavior tests pass. go.mod includes github.com/skip2/go-qrcode and golang.org/x/image.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Cable label renderer + IsCableDevice detection helper</name>
|
||||
<files>internal/labels/cable.go, internal/labels/cable_test.go</files>
|
||||
<behavior>
|
||||
- Test 1: RenderCable produces image.Image of width=384, height=180
|
||||
- Test 2: CableLabelData with USBVersion="USB 3.2 Gen 2", MaxSpeedGbps=10, MaxWatts=100, TestDate="2026-04-13" renders without error
|
||||
- Test 3: IsCableDevice returns true when netbox.Device has a tag named "cable" (case-insensitive) in AINotes JSON or Name contains "cable"
|
||||
- Test 4: IsCableDevice returns false for a device with no cable indicators
|
||||
- Test 5: RenderCable with empty TestDate renders "Not tested" in the test date field without panic
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/labels/cable.go`:
|
||||
|
||||
```go
|
||||
package labels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"strings"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"git.georgsen.dk/hwlab/internal/netbox"
|
||||
)
|
||||
|
||||
const CableLabelHeight = 180
|
||||
|
||||
// CableLabelData holds fields for the cable-specific label template (per LBL-03).
|
||||
type CableLabelData struct {
|
||||
HWID string // e.g. "HW-00042"
|
||||
Name string // e.g. "USB-C 2m Cable"
|
||||
USBVersion string // e.g. "USB 3.2 Gen 2"
|
||||
MaxSpeedGbps float64 // e.g. 10.0
|
||||
MaxWatts int // e.g. 100
|
||||
TestDate string // ISO 8601 or "" for "Not tested"
|
||||
}
|
||||
|
||||
// RenderCable produces a 384x180 label bitmap for a cable item.
|
||||
// Layout: QR code left (96x96), text right with 4 lines:
|
||||
// Line 1: HW ID
|
||||
// Line 2: Name (truncated)
|
||||
// Line 3: USB version + speed (e.g. "USB 3.2 Gen 2 / 10 Gbps")
|
||||
// Line 4: Max power + test date (e.g. "100W Tested: 2026-04-13")
|
||||
func RenderCable(d CableLabelData) (image.Image, error) {
|
||||
if d.Name == "" {
|
||||
d.Name = "Unknown Cable"
|
||||
}
|
||||
testDateStr := d.TestDate
|
||||
if testDateStr == "" {
|
||||
testDateStr = "Not tested"
|
||||
}
|
||||
|
||||
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, CableLabelHeight))
|
||||
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
||||
|
||||
// QR code (same dimensions as standard, vertically centered)
|
||||
qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qr.DisableBorderPadding = true
|
||||
qrImg := qr.Image(QRSize)
|
||||
qrOffsetY := (CableLabelHeight - QRSize) / 2
|
||||
qrDst := image.Rect(QROffsetX, qrOffsetY, QROffsetX+QRSize, qrOffsetY+QRSize)
|
||||
draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over)
|
||||
|
||||
// Text lines (y positions for 180px height, 4 lines)
|
||||
drawText(img, TextOffsetX, 30, d.HWID)
|
||||
drawText(img, TextOffsetX, 62, truncate(d.Name, 30))
|
||||
speedLine := fmt.Sprintf("%s / %.0f Gbps", d.USBVersion, d.MaxSpeedGbps)
|
||||
drawText(img, TextOffsetX, 94, truncate(speedLine, 30))
|
||||
powerLine := fmt.Sprintf("%dW Tested: %s", d.MaxWatts, testDateStr)
|
||||
drawText(img, TextOffsetX, 126, truncate(powerLine, 30))
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// IsCableDevice returns true if the NetBox device record represents a cable.
|
||||
// Detection: Name contains "cable" (case-insensitive) OR AINotes contains "cable".
|
||||
// This is a heuristic — real cable detection uses device_type in Phase 5.
|
||||
func IsCableDevice(d netbox.Device) bool {
|
||||
nameLower := strings.ToLower(d.Name)
|
||||
notesLower := strings.ToLower(d.CustomFields.AINotes)
|
||||
return strings.Contains(nameLower, "cable") || strings.Contains(notesLower, "cable")
|
||||
}
|
||||
```
|
||||
|
||||
Write `internal/labels/cable_test.go` covering the 5 behavior tests.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -v 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>All label tests pass. `go build ./...` succeeds. Both RenderStandard and RenderCable produce valid image.Image values.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| NetBox data → label renderer | Device Name and spec fields flow from NetBox into rendered bitmap text |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-05 | Tampering | drawText / truncate | accept | Labels are rendered locally, not served to external parties; truncate() caps input length preventing oversized text |
|
||||
| T-04-06 | Information Disclosure | QR code URL | accept | URL encodes the HW ID which is already visible on the physical item; no secrets encoded |
|
||||
| T-04-07 | Denial of Service | qrcode.New() with malformed HWID | mitigate | Validate HWID format (HW-\d{5}) before calling RenderStandard/RenderCable; return error on invalid format |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `go test ./internal/labels/... -v` — all tests pass
|
||||
2. `go build ./...` — no compile errors
|
||||
3. Both label heights match spec: standard=120px, cable=180px
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- RenderStandard produces 384x120 bitmap with QR + HW ID + name + spec line
|
||||
- RenderCable produces 384x180 bitmap with QR + 4 text lines including USB version, speed, wattage, test date
|
||||
- IsCableDevice correctly classifies devices by name/notes heuristic
|
||||
- QR encodes correct URL format (http://mac-mini.mg:8080/hw/HW-XXXXX)
|
||||
- All tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md`
|
||||
</output>
|
||||
558
.planning/phases/04-usb-manager-label-printing/04-03-PLAN.md
Normal file
558
.planning/phases/04-usb-manager-label-printing/04-03-PLAN.md
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
---
|
||||
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>
|
||||
254
.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md
Normal file
254
.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
---
|
||||
phase: 04-usb-manager-label-printing
|
||||
plan: "04"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [04-03]
|
||||
files_modified:
|
||||
- internal/api/handlers/intake.go
|
||||
- internal/api/handlers/intake_test.go
|
||||
autonomous: true
|
||||
requirements: [LBL-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "After a successful intake that creates a NetBox record, a label is automatically printed"
|
||||
- "If the printer is unavailable (ErrNoDevice or ErrNotConnected), intake succeeds without printing and the response includes a print_skipped flag"
|
||||
- "Auto-print does not block intake — printer errors are logged and surfaced in response, not returned as HTTP 500"
|
||||
- "Existing intake tests continue to pass unchanged (nil printer = no-op)"
|
||||
artifacts:
|
||||
- path: internal/api/handlers/intake.go
|
||||
provides: "Updated IntakeHandler accepting optional IntakePrinter; auto-prints after record creation"
|
||||
exports: [IntakeHandler, IntakePrinter]
|
||||
- path: internal/api/handlers/intake_test.go
|
||||
provides: "Updated tests covering auto-print success, printer error (non-fatal), nil printer (no-op)"
|
||||
key_links:
|
||||
- from: internal/api/handlers/intake.go
|
||||
to: internal/printer/driver.go
|
||||
via: "IntakePrinter interface satisfied by *printer.MockDriver or *printer.PrtQutieDriver"
|
||||
pattern: "IntakePrinter"
|
||||
- from: internal/api/handlers/intake.go
|
||||
to: internal/labels/renderer.go
|
||||
via: "calls labels.RenderStandard(d) after device creation"
|
||||
pattern: "labels\\.RenderStandard"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Integrate auto-print into the intake flow: after a NetBox record is created successfully, the intake handler calls the label renderer and printer driver. Printer failures must not fail the intake — they are non-fatal and surfaced in the response.
|
||||
|
||||
Purpose: Delivers LBL-05 — label printing as the final step of AI intake without leaving the intake screen. The intake handler already has a clean dependency injection pattern (IntakeOrchestrator, IntakeNetBoxClient, etc.) — follow the same pattern to add IntakePrinter.
|
||||
|
||||
Output: Updated intake handler with optional auto-print, updated tests.
|
||||
</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/handlers/intake.go
|
||||
@internal/api/handlers/intake_test.go
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts from prior plans. -->
|
||||
|
||||
From internal/api/handlers/intake.go (existing, must read before editing):
|
||||
```go
|
||||
type IntakeHandler struct {
|
||||
orchestrator IntakeOrchestrator
|
||||
netboxClient IntakeNetBoxClient
|
||||
catalogUpdater IntakeCatalogUpdater
|
||||
waq IntakeWAQ // may be nil
|
||||
deviceTypeID int32
|
||||
roleID int32
|
||||
siteID int32
|
||||
quickAddEnabled bool
|
||||
quickAddThresh float64
|
||||
}
|
||||
|
||||
func NewIntakeHandler(
|
||||
orch IntakeOrchestrator,
|
||||
nb IntakeNetBoxClient,
|
||||
cu IntakeCatalogUpdater,
|
||||
waq IntakeWAQ,
|
||||
deviceTypeID, roleID, siteID int32,
|
||||
quickAddEnabled bool,
|
||||
quickAddThresh float64,
|
||||
) *IntakeHandler
|
||||
```
|
||||
|
||||
IntakeResponse (existing, to be extended with PrintSkipped):
|
||||
```go
|
||||
type IntakeResponse struct {
|
||||
DeviceID int64 `json:"device_id"`
|
||||
HWID string `json:"hw_id"`
|
||||
CatalogStatus string `json:"catalog_status"`
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
From internal/printer/driver.go (Plan 04-03):
|
||||
```go
|
||||
type PrinterDriver interface {
|
||||
Connect() error
|
||||
Print(bitmap []byte, width, height int) error
|
||||
Disconnect() error
|
||||
}
|
||||
var ErrNoDevice = errors.New("printer: no device found matching VID/PID")
|
||||
var ErrNotConnected = errors.New("printer: not connected")
|
||||
|
||||
func ImageToRawBitmap(img image.Image) ([]byte, int, int)
|
||||
```
|
||||
|
||||
From internal/labels/renderer.go (Plan 04-02):
|
||||
```go
|
||||
type LabelData struct { HWID, Name, SpecLine string }
|
||||
func RenderStandard(d LabelData) (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 }
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add IntakePrinter interface + auto-print to intake handler</name>
|
||||
<files>internal/api/handlers/intake.go, internal/api/handlers/intake_test.go</files>
|
||||
<behavior>
|
||||
- Test 1: Successful intake with a mock printer that returns nil — response contains print_skipped=false
|
||||
- Test 2: Successful intake with a mock printer that returns ErrNoDevice — response contains print_skipped=true, HTTP still 201
|
||||
- Test 3: Successful intake with nil printer — response contains print_skipped=true (no-op path), no panic
|
||||
- Test 4: Printer error does NOT cause intake to return 500 — device record was created, so 201 is returned
|
||||
- Test 5: All 6 existing intake tests continue to pass (nil printer = existing behavior preserved)
|
||||
</behavior>
|
||||
<action>
|
||||
Read `internal/api/handlers/intake.go` fully before editing.
|
||||
|
||||
Add to `internal/api/handlers/intake.go`:
|
||||
|
||||
1. New interface (follow the existing interface pattern in this file):
|
||||
```go
|
||||
// IntakePrinter is the optional printer used to auto-print a label after intake.
|
||||
// If nil, label printing is skipped. Printer errors are non-fatal.
|
||||
type IntakePrinter interface {
|
||||
Print(bitmap []byte, width, height int) error
|
||||
}
|
||||
```
|
||||
|
||||
2. Add `printer IntakePrinter` field to `IntakeHandler` struct (after `waq`).
|
||||
|
||||
3. Update `NewIntakeHandler` signature — add `p IntakePrinter` as the last parameter:
|
||||
```go
|
||||
func NewIntakeHandler(
|
||||
orch IntakeOrchestrator,
|
||||
nb IntakeNetBoxClient,
|
||||
cu IntakeCatalogUpdater,
|
||||
waq IntakeWAQ,
|
||||
deviceTypeID, roleID, siteID int32,
|
||||
quickAddEnabled bool,
|
||||
quickAddThresh float64,
|
||||
p IntakePrinter, // NEW — may be nil
|
||||
) *IntakeHandler
|
||||
```
|
||||
|
||||
4. Update `IntakeResponse` — add `PrintSkipped bool`:
|
||||
```go
|
||||
type IntakeResponse struct {
|
||||
// ... existing fields unchanged ...
|
||||
PrintSkipped bool `json:"print_skipped"`
|
||||
}
|
||||
```
|
||||
|
||||
5. In `ServeHTTP`, after the NetBox record is successfully created (after `catalogUpdater.UpdateCatalogStatus` succeeds), add the auto-print step:
|
||||
```go
|
||||
// Auto-print label (non-fatal: printer errors skip printing but don't fail intake)
|
||||
resp.PrintSkipped = true
|
||||
if h.printer != nil {
|
||||
// Fetch the created device to build LabelData
|
||||
// Use the deviceID and HWID already available in the response
|
||||
labelData := labels.LabelData{
|
||||
HWID: hwid, // already allocated earlier in the handler
|
||||
Name: result.Name, // from AI IntakeResult
|
||||
SpecLine: result.SpecLine, // from AI IntakeResult (top spec summary)
|
||||
}
|
||||
img, renderErr := labels.RenderStandard(labelData)
|
||||
if renderErr == nil {
|
||||
bitmap, w, h := printer.ImageToRawBitmap(img)
|
||||
printErr := h.printer.Print(bitmap, w, h)
|
||||
if printErr == nil {
|
||||
resp.PrintSkipped = false
|
||||
} else {
|
||||
log.Printf("auto-print skipped: %v", printErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("label render failed: %v", renderErr)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `result.SpecLine` may not exist yet on `ai.IntakeResult`. Check the struct definition in `internal/ai/` — if there is no SpecLine field, use `result.AINotes` or the first spec from `result.Specs` as a fallback. Do NOT add a field to IntakeResult if it does not already exist — use what is there.
|
||||
|
||||
6. Update `cmd/hwlab/main.go` to pass the MockDriver instance to `NewIntakeHandler` as the last argument.
|
||||
|
||||
7. Update all existing `newHandler(...)` calls in `intake_test.go` to pass `nil` as the last argument — preserving the 6 existing test behaviors exactly.
|
||||
|
||||
Write 4 new tests (Tests 1-4) in `intake_test.go` using a `mockPrinter` type:
|
||||
```go
|
||||
type mockPrinter struct {
|
||||
returnErr error
|
||||
called bool
|
||||
}
|
||||
func (m *mockPrinter) Print(bitmap []byte, w, h int) error {
|
||||
m.called = true
|
||||
return m.returnErr
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestIntake" 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<done>All intake tests pass (existing 6 + new 4). `go build ./cmd/hwlab/...` succeeds. IntakePrinter interface accepted as nil safely.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| intake flow → printer | Label printed after record creation; print failure must not corrupt the intake record |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-12 | Tampering | auto-print data | accept | LabelData derived from NetBox-confirmed record; no user-supplied raw input flows to printer at this stage |
|
||||
| T-04-13 | Denial of Service | print loop in intake | mitigate | Print is called once per intake, synchronous, with its own error boundary — cannot loop or recurse |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `go test ./internal/api/handlers/... -v` — all tests pass (old + new)
|
||||
2. `go build ./cmd/hwlab/...` — compiles with updated NewIntakeHandler signature
|
||||
3. Test with nil printer: intake returns 201 with print_skipped=true
|
||||
4. Test with mock printer returning ErrNoDevice: intake returns 201 with print_skipped=true
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- IntakePrinter interface added to intake.go
|
||||
- Auto-print step fires after NetBox record creation
|
||||
- Printer failure (any error) sets print_skipped=true and logs — never 500
|
||||
- nil printer behaves as no-op (print_skipped=true)
|
||||
- All existing intake tests pass unchanged (nil passed as printer)
|
||||
- New tests cover the 4 printer scenarios
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md`
|
||||
</output>
|
||||
329
.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md
Normal file
329
.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
---
|
||||
phase: 04-usb-manager-label-printing
|
||||
plan: "05"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [04-03]
|
||||
files_modified:
|
||||
- web/src/components/USBStatusBar.tsx
|
||||
- web/src/hooks/useUSBEvents.ts
|
||||
- web/src/pages/DashboardPage.tsx
|
||||
autonomous: true
|
||||
requirements: [USB-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "USB device connect/disconnect events appear in the frontend in real time"
|
||||
- "Dashboard shows a USB status bar with connected device names and roles"
|
||||
- "Print Label button on item cards calls POST /api/labels/:id/print and shows a toast on success/failure"
|
||||
- "USB status bar updates without a page reload when a device is plugged or unplugged"
|
||||
artifacts:
|
||||
- path: web/src/hooks/useUSBEvents.ts
|
||||
provides: "React hook subscribing to GET /api/usb/events SSE stream, returns DeviceEvent[]"
|
||||
exports: [useUSBEvents, DeviceEvent, DeviceState]
|
||||
- path: web/src/components/USBStatusBar.tsx
|
||||
provides: "USBStatusBar component showing connected USB devices with role icons"
|
||||
exports: [USBStatusBar]
|
||||
- path: web/src/pages/DashboardPage.tsx
|
||||
provides: "Updated dashboard with USBStatusBar in header area and Print Label quick action on item cards"
|
||||
key_links:
|
||||
- from: web/src/hooks/useUSBEvents.ts
|
||||
to: GET /api/usb/events
|
||||
via: "new EventSource('/api/usb/events') with onmessage handler"
|
||||
pattern: "EventSource"
|
||||
- from: web/src/pages/DashboardPage.tsx
|
||||
to: POST /api/labels/:id/print
|
||||
via: "fetch('/api/labels/${id}/print', {method:'POST'}) in handlePrintLabel"
|
||||
pattern: "api/labels"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the frontend USB status bar and wire the print label quick action on dashboard item cards.
|
||||
|
||||
Purpose: Delivers the user-visible half of USB-04 — device events arrive from the SSE stream and update the UI without polling or page reloads. Also surfaces the print action from the dashboard.
|
||||
|
||||
Output: `useUSBEvents` hook, `USBStatusBar` component, updated `DashboardPage` with print button.
|
||||
</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
|
||||
@web/src/pages/DashboardPage.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- SSE event shape from Plan 04-03 (usb_events.go): -->
|
||||
|
||||
SSE message format — each event is `data: {JSON}\n\n` where JSON is:
|
||||
```typescript
|
||||
interface DeviceEvent {
|
||||
VIDPID: string; // e.g. "0525:a4a7"
|
||||
Spec: {
|
||||
VID: string;
|
||||
PID: string;
|
||||
Name: string; // e.g. "PRT Qutie"
|
||||
Role: number; // 0=Printer, 1=CableTester, 2=Unknown
|
||||
BaudRate: number;
|
||||
};
|
||||
State: number; // 0=Disconnected, 1=Connected
|
||||
}
|
||||
|
||||
type DeviceState = 0 | 1;
|
||||
const StateDisconnected: DeviceState = 0;
|
||||
const StateConnected: DeviceState = 1;
|
||||
```
|
||||
|
||||
Print endpoint response:
|
||||
```typescript
|
||||
// POST /api/labels/:deviceID/print
|
||||
// 200: { status: "printed" }
|
||||
// 200 with print_skipped: { status: "ok", print_skipped: true } (printer unavailable)
|
||||
// 404: { error: "device not found" }
|
||||
// 500: { error: "..." }
|
||||
```
|
||||
|
||||
ClickHouse design system (from CLAUDE.md global):
|
||||
- Background: #000000 (pure black)
|
||||
- Accent: #faff69 (neon volt)
|
||||
- Status indicators: connected = #22c55e (green-500), disconnected = #ef4444 (red-500)
|
||||
- Font: existing Tailwind classes, no custom CSS
|
||||
|
||||
Existing DashboardPage patterns (read the file before editing):
|
||||
- Uses TanStack Query for inventory data
|
||||
- Uses react-hot-toast for notifications
|
||||
- Item cards are rendered via ItemCard component or inline map
|
||||
- Quick actions are icon buttons in the card's action area
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: useUSBEvents hook + USBStatusBar component</name>
|
||||
<files>web/src/hooks/useUSBEvents.ts, web/src/components/USBStatusBar.tsx</files>
|
||||
<action>
|
||||
Read `web/src/pages/DashboardPage.tsx` to understand the existing component patterns before writing.
|
||||
|
||||
Create `web/src/hooks/useUSBEvents.ts`:
|
||||
|
||||
```typescript
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const StateDisconnected = 0;
|
||||
export const StateConnected = 1;
|
||||
|
||||
export interface DeviceSpec {
|
||||
VID: string;
|
||||
PID: string;
|
||||
Name: string;
|
||||
Role: number; // 0=Printer, 1=CableTester, 2=Unknown
|
||||
BaudRate: number;
|
||||
}
|
||||
|
||||
export interface DeviceEvent {
|
||||
VIDPID: string;
|
||||
Spec: DeviceSpec;
|
||||
State: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* useUSBEvents subscribes to the GET /api/usb/events SSE stream and maintains
|
||||
* the current map of connected USB devices.
|
||||
*
|
||||
* Returns: connectedDevices (map of VIDPID → DeviceSpec) and the last raw event.
|
||||
*/
|
||||
export function useUSBEvents(): {
|
||||
connectedDevices: Map<string, DeviceSpec>;
|
||||
lastEvent: DeviceEvent | null;
|
||||
} {
|
||||
const [connectedDevices, setConnectedDevices] = useState<Map<string, DeviceSpec>>(new Map());
|
||||
const [lastEvent, setLastEvent] = useState<DeviceEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/usb/events');
|
||||
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const event: DeviceEvent = JSON.parse(e.data);
|
||||
setLastEvent(event);
|
||||
setConnectedDevices((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (event.State === StateConnected) {
|
||||
next.set(event.VIDPID, event.Spec);
|
||||
} else {
|
||||
next.delete(event.VIDPID);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Malformed SSE data — ignore, keep existing state
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// SSE error (server down, network issue) — EventSource auto-reconnects
|
||||
// No state change needed; devices remain as last known
|
||||
};
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { connectedDevices, lastEvent };
|
||||
}
|
||||
```
|
||||
|
||||
Create `web/src/components/USBStatusBar.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Printer, Cable, Usb } from 'lucide-react';
|
||||
import { useUSBEvents, DeviceSpec } from '../hooks/useUSBEvents';
|
||||
|
||||
const roleIcon = (role: number) => {
|
||||
switch (role) {
|
||||
case 0: return <Printer className="w-4 h-4" />;
|
||||
case 1: return <Cable className="w-4 h-4" />;
|
||||
default: return <Usb className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* USBStatusBar displays connected USB devices in a compact horizontal bar.
|
||||
* Shows a dot indicator per device; empty state shows "No USB devices".
|
||||
* Uses ClickHouse design system: black bg, neon volt accent, green for connected.
|
||||
*/
|
||||
export function USBStatusBar() {
|
||||
const { connectedDevices } = useUSBEvents();
|
||||
const devices = Array.from(connectedDevices.entries());
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-black border border-white/10 rounded text-xs text-white/40">
|
||||
<Usb className="w-3.5 h-3.5" />
|
||||
<span>No USB devices</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 bg-black border border-white/10 rounded">
|
||||
{devices.map(([vidpid, spec]: [string, DeviceSpec]) => (
|
||||
<div key={vidpid} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
|
||||
{roleIcon(spec.Role)}
|
||||
<span className="text-white/80">{spec.Name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>TypeScript compilation clean. useUSBEvents hook and USBStatusBar component created with no type errors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire USBStatusBar + Print Label button into DashboardPage</name>
|
||||
<files>web/src/pages/DashboardPage.tsx</files>
|
||||
<action>
|
||||
Read `web/src/pages/DashboardPage.tsx` fully before editing.
|
||||
|
||||
Make two targeted additions to DashboardPage:
|
||||
|
||||
**1. Import and place USBStatusBar** in the page header area (above the inventory grid, alongside the existing filter controls). Find the header/toolbar section and add:
|
||||
```tsx
|
||||
import { USBStatusBar } from '../components/USBStatusBar';
|
||||
|
||||
// In the header JSX, alongside existing controls:
|
||||
<USBStatusBar />
|
||||
```
|
||||
|
||||
**2. Add Print Label quick action button** to each item card. Find where item quick actions are rendered (look for the existing "Print Label" or "View in NetBox" / "Edit" action area). If a print action already exists as a stub, wire it; if not, add a Printer icon button:
|
||||
|
||||
```tsx
|
||||
import toast from 'react-hot-toast';
|
||||
import { Printer } from 'lucide-react';
|
||||
|
||||
// In the item card action area (per item):
|
||||
const handlePrintLabel = async (deviceId: number) => {
|
||||
const toastId = toast.loading('Printing label...');
|
||||
try {
|
||||
const res = await fetch(`/api/labels/${deviceId}/print`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.error ?? 'Print failed', { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (data.print_skipped) {
|
||||
toast('Label queued — printer not connected', { id: toastId, icon: '⚠️' });
|
||||
} else {
|
||||
toast.success('Label printed', { id: toastId });
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
// Button (in each card's action area):
|
||||
<button
|
||||
onClick={() => handlePrintLabel(item.id)}
|
||||
className="p-1.5 rounded hover:bg-white/10 text-white/60 hover:text-[#faff69] transition-colors"
|
||||
title="Print label"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
Move `handlePrintLabel` outside the map callback (define once at component level, not per-render).
|
||||
|
||||
Do not restructure the existing DashboardPage layout — make additive changes only. Preserve all existing functionality (grid/list toggle, filters, item navigation).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit && npx eslint src/pages/DashboardPage.tsx --max-warnings 0 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>TypeScript and ESLint clean. USBStatusBar renders in dashboard header. Print Label button present on each item card.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| SSE stream → React state | Parsed JSON from server updates UI state |
|
||||
| Print button → POST /api/labels | User-triggered HTTP call from browser |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-14 | Tampering | SSE JSON parse | mitigate | Wrap JSON.parse in try/catch; malformed data is ignored, state unchanged |
|
||||
| T-04-15 | Denial of Service | EventSource reconnect loop | accept | Browser EventSource auto-reconnects with backoff; solo-operator, not a threat vector |
|
||||
| T-04-16 | Information Disclosure | Device names in SSE | accept | Device names visible on LAN only; PRT Qutie name is not sensitive |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` in web/ — clean
|
||||
2. `npx eslint src/` — no errors or warnings on new files
|
||||
3. `npm run build` — production build succeeds
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- useUSBEvents hook subscribes to /api/usb/events SSE and maintains connected device map
|
||||
- USBStatusBar shows connected devices with role icons and green status dots; empty state when none
|
||||
- DashboardPage header includes USBStatusBar
|
||||
- Each item card has a Printer icon button wired to POST /api/labels/:id/print
|
||||
- Print success shows success toast; printer unavailable shows warning toast; error shows error toast
|
||||
- No TypeScript errors, no ESLint errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-usb-manager-label-printing/04-05-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue