feat(04-04): integrate auto-print into intake handler
- Add IntakePrinter interface to intake.go (optional, nil-safe) - Add printer field to IntakeHandler, update NewIntakeHandler signature - Add PrintSkipped bool to IntakeResponse (json: print_skipped) - Auto-print label after NetBox record creation using labels.RenderStandard + printer.ImageToRawBitmap - Printer errors are non-fatal: logged and surfaced via print_skipped=true - Update main.go to pass mockDriver as IntakePrinter - Add 4 new tests covering success, ErrNoDevice, nil printer, and non-fatal error - All 10 intake tests pass (6 existing + 4 new)
This commit is contained in:
parent
4d2d35e277
commit
f9b1b3ff29
3 changed files with 165 additions and 17 deletions
|
|
@ -66,19 +66,6 @@ func main() {
|
||||||
log.Printf("WAQ worker started")
|
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.
|
// USB Manager — polls for device connect/disconnect events.
|
||||||
// Start with 2-second poll interval (production default).
|
// Start with 2-second poll interval (production default).
|
||||||
usbManager := usb.NewManager(2 * time.Second)
|
usbManager := usb.NewManager(2 * time.Second)
|
||||||
|
|
@ -92,6 +79,20 @@ func main() {
|
||||||
log.Printf("WARNING: mock printer connect: %v", err)
|
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)
|
inventoryHandler := handlers.NewInventoryHandler(nbClient)
|
||||||
labelHandler := handlers.NewLabelHandler(nbClient, mockDriver)
|
labelHandler := handlers.NewLabelHandler(nbClient, mockDriver)
|
||||||
usbEventsHandler := handlers.NewUSBEventsHandler(usbManager)
|
usbEventsHandler := handlers.NewUSBEventsHandler(usbManager)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import (
|
||||||
|
|
||||||
"git.georgsen.dk/hwlab/internal/ai"
|
"git.georgsen.dk/hwlab/internal/ai"
|
||||||
"git.georgsen.dk/hwlab/internal/inventory"
|
"git.georgsen.dk/hwlab/internal/inventory"
|
||||||
|
"git.georgsen.dk/hwlab/internal/labels"
|
||||||
"git.georgsen.dk/hwlab/internal/netbox"
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
"git.georgsen.dk/hwlab/internal/printer"
|
||||||
"git.georgsen.dk/hwlab/internal/queue"
|
"git.georgsen.dk/hwlab/internal/queue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,13 +43,20 @@ type IntakeWAQ interface {
|
||||||
Enqueue(ctx context.Context, op queue.PendingOp) error
|
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 →
|
// IntakeHandler handles POST /api/intake — multipart photo upload → AI analysis →
|
||||||
// HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure).
|
// HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure).
|
||||||
type IntakeHandler struct {
|
type IntakeHandler struct {
|
||||||
orchestrator IntakeOrchestrator
|
orchestrator IntakeOrchestrator
|
||||||
netboxClient IntakeNetBoxClient
|
netboxClient IntakeNetBoxClient
|
||||||
catalogUpdater IntakeCatalogUpdater
|
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
|
deviceTypeID int32
|
||||||
roleID int32
|
roleID int32
|
||||||
siteID int32
|
siteID int32
|
||||||
|
|
@ -55,7 +64,7 @@ type IntakeHandler struct {
|
||||||
quickAddThresh float64
|
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(
|
func NewIntakeHandler(
|
||||||
orch IntakeOrchestrator,
|
orch IntakeOrchestrator,
|
||||||
nb IntakeNetBoxClient,
|
nb IntakeNetBoxClient,
|
||||||
|
|
@ -64,12 +73,14 @@ func NewIntakeHandler(
|
||||||
deviceTypeID, roleID, siteID int32,
|
deviceTypeID, roleID, siteID int32,
|
||||||
quickAddEnabled bool,
|
quickAddEnabled bool,
|
||||||
quickAddThresh float64,
|
quickAddThresh float64,
|
||||||
|
p IntakePrinter, // may be nil — label printing will be skipped
|
||||||
) *IntakeHandler {
|
) *IntakeHandler {
|
||||||
return &IntakeHandler{
|
return &IntakeHandler{
|
||||||
orchestrator: orch,
|
orchestrator: orch,
|
||||||
netboxClient: nb,
|
netboxClient: nb,
|
||||||
catalogUpdater: cu,
|
catalogUpdater: cu,
|
||||||
waq: waq,
|
waq: waq,
|
||||||
|
printer: p,
|
||||||
deviceTypeID: deviceTypeID,
|
deviceTypeID: deviceTypeID,
|
||||||
roleID: roleID,
|
roleID: roleID,
|
||||||
siteID: siteID,
|
siteID: siteID,
|
||||||
|
|
@ -90,7 +101,8 @@ type IntakeResponse struct {
|
||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
CatalogStatus string `json:"catalog_status"`
|
CatalogStatus string `json:"catalog_status"`
|
||||||
NetBoxID int64 `json:"netbox_id,omitempty"`
|
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.
|
// ServeHTTP implements http.Handler for the intake endpoint.
|
||||||
|
|
@ -239,6 +251,31 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// Non-fatal
|
// 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{
|
writeJSON(w, http.StatusCreated, IntakeResponse{
|
||||||
HWID: hwid,
|
HWID: hwid,
|
||||||
Model: result.Model,
|
Model: result.Model,
|
||||||
|
|
@ -250,6 +287,7 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
Confidence: result.Confidence,
|
Confidence: result.Confidence,
|
||||||
CatalogStatus: string(status),
|
CatalogStatus: string(status),
|
||||||
NetBoxID: deviceID,
|
NetBoxID: deviceID,
|
||||||
|
PrintSkipped: printSkipped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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 ---
|
// --- Tests ---
|
||||||
|
|
@ -266,3 +278,100 @@ func TestIntakeHandlerNetBoxDown(t *testing.T) {
|
||||||
t.Errorf("expected 1 op enqueued, got %d", len(waq.enqueued))
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue