homelabby/.planning/phases/04-usb-manager-label-printing/04-04-PLAN.md
Mikkel Georgsen 77bf4ebfd6 docs(04): create phase 4 plans — USB manager, label printing, SSE, intake integration
5 plans across 3 waves covering USB-01 through USB-04 and LBL-01 through LBL-05.
Mock drivers and goroutine-leak harness tests enable full TDD before hardware arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:41:26 +00:00

254 lines
9.5 KiB
Markdown

---
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>&amp;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>