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)") + } +}