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

9.5 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-usb-manager-label-printing 04 execute 3
04-03
internal/api/handlers/intake.go
internal/api/handlers/intake_test.go
true
LBL-05
truths artifacts key_links
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)
path provides exports
internal/api/handlers/intake.go Updated IntakeHandler accepting optional IntakePrinter; auto-prints after record creation
IntakeHandler
IntakePrinter
path provides
internal/api/handlers/intake_test.go Updated tests covering auto-print success, printer error (non-fatal), nil printer (no-op)
from to via pattern
internal/api/handlers/intake.go internal/printer/driver.go IntakePrinter interface satisfied by *printer.MockDriver or *printer.PrtQutieDriver IntakePrinter
from to via pattern
internal/api/handlers/intake.go internal/labels/renderer.go calls labels.RenderStandard(d) after device creation labels.RenderStandard
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.

<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/handlers/intake.go @internal/api/handlers/intake_test.go

From internal/api/handlers/intake.go (existing, must read before editing):

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

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

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

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:

type Device struct { ID int; Name string; AssetTag string; CustomFields CustomFields }
Task 1: Add IntakePrinter interface + auto-print to intake handler internal/api/handlers/intake.go, internal/api/handlers/intake_test.go - 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) 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):
// 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
}
  1. Add printer IntakePrinter field to IntakeHandler struct (after waq).

  2. Update NewIntakeHandler signature — add p IntakePrinter as the last parameter:

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
  1. Update IntakeResponse — add PrintSkipped bool:
type IntakeResponse struct {
    // ... existing fields unchanged ...
    PrintSkipped bool `json:"print_skipped"`
}
  1. In ServeHTTP, after the NetBox record is successfully created (after catalogUpdater.UpdateCatalogStatus succeeds), add the auto-print step:
// 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.

  1. Update cmd/hwlab/main.go to pass the MockDriver instance to NewIntakeHandler as the last argument.

  2. 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:

type mockPrinter struct {
    returnErr error
    called    bool
}
func (m *mockPrinter) Print(bitmap []byte, w, h int) error {
    m.called = true
    return m.returnErr
}
cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v -run "TestIntake" 2>&1 | tail -30 All intake tests pass (existing 6 + new 4). `go build ./cmd/hwlab/...` succeeds. IntakePrinter interface accepted as nil safely.

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

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