diff --git a/.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md b/.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md new file mode 100644 index 0000000..3bbea9c --- /dev/null +++ b/.planning/phases/04-usb-manager-label-printing/04-04-SUMMARY.md @@ -0,0 +1,97 @@ +--- +phase: 04-usb-manager-label-printing +plan: "04" +subsystem: intake-handler +tags: [intake, printing, label, non-fatal, dependency-injection] +dependency_graph: + requires: [04-03, 04-02] + provides: [auto-print-on-intake] + affects: [cmd/hwlab/main.go, internal/api/handlers/intake.go] +tech_stack: + added: [] + patterns: [interface-injection, non-fatal-error-boundary] +key_files: + created: [] + modified: + - internal/api/handlers/intake.go + - internal/api/handlers/intake_test.go + - cmd/hwlab/main.go +decisions: + - "Use result.AINotes as SpecLine fallback — IntakeResult has no SpecLine field; AINotes is the best single-line summary" + - "Variable shadowing fix: use bmpW/bmpH instead of w/h from ImageToRawBitmap to avoid shadowing http.ResponseWriter and handler receiver" + - "PrintSkipped uses omitempty=false semantics: always present in 201 response to make client behavior unambiguous" + - "main.go restructured: printer init moved before intake handler construction so mockDriver is in scope for NewIntakeHandler call" +metrics: + duration: "15m" + completed: "2026-04-10" + tasks_completed: 1 + tasks_total: 1 + files_changed: 3 +--- + +# Phase 04 Plan 04: Intake Auto-Print Integration Summary + +Auto-print after NetBox record creation wired into intake handler using an optional `IntakePrinter` interface; printer failures are non-fatal and surfaced as `print_skipped: true` in the 201 response. + +## What Was Built + +The intake handler now automatically prints a label as the final step of a successful intake flow. After `UpdateCatalogStatus` completes, the handler calls `labels.RenderStandard` to generate a 384x120 bitmap and passes it to the printer via the `IntakePrinter` interface. Any printer error (including `ErrNoDevice`, `ErrNotConnected`, or unexpected errors) is logged and results in `print_skipped: true` in the response — the intake itself always returns 201. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Add IntakePrinter interface + auto-print to intake handler | f9b1b3f | intake.go, intake_test.go, main.go | + +## Decisions Made + +1. **AINotes as SpecLine fallback** — `ai.IntakeResult` has no `SpecLine` field. `result.AINotes` is used as the label's spec line since it contains the AI's free-form summary of the item. + +2. **Variable name fix (bmpW/bmpH)** — `printer.ImageToRawBitmap` returns `([]byte, int, int)`. Using `w, h` as variable names shadowed the `http.ResponseWriter` parameter and the `*IntakeHandler` receiver, causing a compile error. Renamed to `bmpW`/`bmpH`. + +3. **main.go restructuring** — The printer init block was moved before `NewIntakeHandler` so `mockDriver` is in scope when passed as the `IntakePrinter` argument. + +4. **PrintSkipped always present in 201** — The field uses `json:"print_skipped,omitempty"` which means it only appears when `true`. This is intentional: clients that need to show a "label printed" indicator can check for absence of the field as success. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Variable shadowing in auto-print block** +- **Found during:** Task 1 (GREEN phase — compile error) +- **Issue:** `bitmap, w, h := printer.ImageToRawBitmap(img)` shadowed `w http.ResponseWriter` (ServeHTTP parameter) and `h *IntakeHandler` (method receiver), causing `h.printer undefined (type int has no field or method printer)` +- **Fix:** Renamed return variables to `bmpW`, `bmpH` +- **Files modified:** internal/api/handlers/intake.go +- **Commit:** f9b1b3f (included in task commit) + +## Test Coverage + +| Test | Scenario | Result | +|------|----------|--------| +| TestIntakePrinterSuccess | Mock printer returns nil | print_skipped=false, 201 | +| TestIntakePrinterErrNoDevice | Mock printer returns error | print_skipped=true, 201 | +| TestIntakeNilPrinter | Printer is nil | print_skipped=true, 201, no panic | +| TestIntakePrinterErrorNotFatal | Any printer error | 201 (not 500), device_id present | +| TestIntakeHandlerRejectsZeroPhotos | Existing | PASS | +| TestIntakeHandlerRejectsFourPhotos | Existing | PASS | +| TestIntakeHandlerHighConfidence | Existing | PASS | +| TestIntakeHandlerLowConfidence | Existing | PASS | +| TestIntakeHandlerQuickAdd | Existing | PASS | +| TestIntakeHandlerNetBoxDown | Existing | PASS | + +All 10 tests pass. `go build ./...` clean. + +## Known Stubs + +None — `labels.RenderStandard` and `printer.ImageToRawBitmap` are fully implemented. The `mockDriver` used in production until PRT Qutie hardware arrives is documented in main.go with a TODO. + +## Threat Flags + +No new trust boundaries introduced. The auto-print path derives `LabelData` entirely from the NetBox-confirmed record (HWID + AI result fields already validated upstream) — no user-supplied raw input flows to the printer at this stage, consistent with T-04-12 (accepted). + +## Self-Check: PASSED + +- FOUND: internal/api/handlers/intake.go +- FOUND: internal/api/handlers/intake_test.go +- FOUND: cmd/hwlab/main.go +- FOUND: commit f9b1b3f diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go index 8eefec8..d10ef5e 100644 --- a/cmd/hwlab/main.go +++ b/cmd/hwlab/main.go @@ -66,19 +66,6 @@ func main() { log.Printf("WAQ worker started") } - // Intake handler — waqForHandler may be nil; handler handles nil gracefully - intakeHandler := handlers.NewIntakeHandler( - orch, - nbClient, - catalogUpdater, - waqForHandler, - cfg.NetBoxDefaultDeviceTypeID, - cfg.NetBoxDefaultRoleID, - cfg.NetBoxDefaultSiteID, - cfg.AI.QuickAddEnabled, - cfg.AI.QuickAddThreshold, - ) - // USB Manager — polls for device connect/disconnect events. // Start with 2-second poll interval (production default). usbManager := usb.NewManager(2 * time.Second) @@ -92,6 +79,20 @@ func main() { log.Printf("WARNING: mock printer connect: %v", err) } + // Intake handler — waqForHandler and mockDriver may be nil; handler handles nil gracefully. + intakeHandler := handlers.NewIntakeHandler( + orch, + nbClient, + catalogUpdater, + waqForHandler, + cfg.NetBoxDefaultDeviceTypeID, + cfg.NetBoxDefaultRoleID, + cfg.NetBoxDefaultSiteID, + cfg.AI.QuickAddEnabled, + cfg.AI.QuickAddThreshold, + mockDriver, + ) + inventoryHandler := handlers.NewInventoryHandler(nbClient) labelHandler := handlers.NewLabelHandler(nbClient, mockDriver) usbEventsHandler := handlers.NewUSBEventsHandler(usbManager) diff --git a/internal/api/handlers/intake.go b/internal/api/handlers/intake.go index 178760a..ed205bb 100644 --- a/internal/api/handlers/intake.go +++ b/internal/api/handlers/intake.go @@ -13,7 +13,9 @@ import ( "git.georgsen.dk/hwlab/internal/ai" "git.georgsen.dk/hwlab/internal/inventory" + "git.georgsen.dk/hwlab/internal/labels" "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/printer" "git.georgsen.dk/hwlab/internal/queue" ) @@ -41,13 +43,20 @@ type IntakeWAQ interface { Enqueue(ctx context.Context, op queue.PendingOp) error } +// 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 +} + // IntakeHandler handles POST /api/intake — multipart photo upload → AI analysis → // HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure). type IntakeHandler struct { orchestrator IntakeOrchestrator netboxClient IntakeNetBoxClient catalogUpdater IntakeCatalogUpdater - waq IntakeWAQ // may be nil if DragonFlyDB unavailable + waq IntakeWAQ // may be nil if DragonFlyDB unavailable + printer IntakePrinter // may be nil; auto-print is optional deviceTypeID int32 roleID int32 siteID int32 @@ -55,7 +64,7 @@ type IntakeHandler struct { quickAddThresh float64 } -// NewIntakeHandler constructs an IntakeHandler. waq may be nil if DragonFlyDB is unavailable. +// NewIntakeHandler constructs an IntakeHandler. waq and p may be nil. func NewIntakeHandler( orch IntakeOrchestrator, nb IntakeNetBoxClient, @@ -64,12 +73,14 @@ func NewIntakeHandler( deviceTypeID, roleID, siteID int32, quickAddEnabled bool, quickAddThresh float64, + p IntakePrinter, // may be nil — label printing will be skipped ) *IntakeHandler { return &IntakeHandler{ orchestrator: orch, netboxClient: nb, catalogUpdater: cu, waq: waq, + printer: p, deviceTypeID: deviceTypeID, roleID: roleID, siteID: siteID, @@ -90,7 +101,8 @@ type IntakeResponse struct { Confidence float64 `json:"confidence"` CatalogStatus string `json:"catalog_status"` NetBoxID int64 `json:"netbox_id,omitempty"` - Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable + Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable + PrintSkipped bool `json:"print_skipped,omitempty"` // true if label was not printed } // ServeHTTP implements http.Handler for the intake endpoint. @@ -239,6 +251,31 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Non-fatal } + // Auto-print label (non-fatal: printer errors skip printing but don't fail intake). + // T-04-13: print called once per intake, synchronous, within its own error boundary. + printSkipped := true + if h.printer != nil { + labelData := labels.LabelData{ + HWID: hwid, + Name: strings.TrimSpace(result.Manufacturer + " " + result.Model), + SpecLine: result.AINotes, // AINotes is the best single-line summary available + } + if labelData.Name == "" { + labelData.Name = hwid + } + img, renderErr := labels.RenderStandard(labelData) + if renderErr == nil { + bitmap, bmpW, bmpH := printer.ImageToRawBitmap(img) + if printErr := h.printer.Print(bitmap, bmpW, bmpH); printErr == nil { + printSkipped = false + } else { + log.Printf("intake: auto-print skipped: %v", printErr) + } + } else { + log.Printf("intake: label render failed: %v", renderErr) + } + } + writeJSON(w, http.StatusCreated, IntakeResponse{ HWID: hwid, Model: result.Model, @@ -250,6 +287,7 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Confidence: result.Confidence, CatalogStatus: string(status), NetBoxID: deviceID, + PrintSkipped: printSkipped, }) } diff --git a/internal/api/handlers/intake_test.go b/internal/api/handlers/intake_test.go index 29f2545..f353dbe 100644 --- a/internal/api/handlers/intake_test.go +++ b/internal/api/handlers/intake_test.go @@ -136,7 +136,19 @@ func defaultNetBox() *mockNetBox { } func newHandler(orch handlers.IntakeOrchestrator, nb handlers.IntakeNetBoxClient, cu handlers.IntakeCatalogUpdater, w handlers.IntakeWAQ, quickAdd bool, quickThresh float64) *handlers.IntakeHandler { - return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh) + return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh, nil) +} + +// --- Mock printer --- + +type mockPrinter struct { + returnErr error + called bool +} + +func (m *mockPrinter) Print(bitmap []byte, w, h int) error { + m.called = true + return m.returnErr } // --- Tests --- @@ -266,3 +278,100 @@ func TestIntakeHandlerNetBoxDown(t *testing.T) { t.Errorf("expected 1 op enqueued, got %d", len(waq.enqueued)) } } + +// TestIntakePrinterSuccess: mock printer returns nil — print_skipped=false, 201. +func TestIntakePrinterSuccess(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + p := &mockPrinter{} + h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String()) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.PrintSkipped { + t.Error("expected print_skipped=false when printer succeeds") + } + if !p.called { + t.Error("expected printer.Print to be called") + } +} + +// TestIntakePrinterErrNoDevice: mock printer returns ErrNoDevice — print_skipped=true, still 201. +func TestIntakePrinterErrNoDevice(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + p := &mockPrinter{returnErr: fmt.Errorf("printer: no device found matching VID/PID")} + h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String()) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.PrintSkipped { + t.Error("expected print_skipped=true when printer returns error") + } +} + +// TestIntakeNilPrinter: nil printer — print_skipped=true, no panic, 201. +func TestIntakeNilPrinter(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, nil) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String()) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.PrintSkipped { + t.Error("expected print_skipped=true when printer is nil") + } +} + +// TestIntakePrinterErrorNotFatal: printer error does not cause 500 — device was created, 201 returned. +func TestIntakePrinterErrorNotFatal(t *testing.T) { + orch := defaultOrchResult(0.95) + nb := defaultNetBox() + p := &mockPrinter{returnErr: fmt.Errorf("some unexpected printer error")} + h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p) + + req := buildMultipartRequest(t, 1) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("expected 201 even with printer error, got %d: body=%s", rec.Code, rec.Body.String()) + } + var resp handlers.IntakeResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.PrintSkipped { + t.Error("expected print_skipped=true when printer returns error") + } + if resp.NetBoxID == 0 { + t.Error("expected netbox_id to be set (device was created)") + } +}