diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go index 95a3d23..8eefec8 100644 --- a/cmd/hwlab/main.go +++ b/cmd/hwlab/main.go @@ -17,7 +17,9 @@ import ( "git.georgsen.dk/hwlab/internal/config" "git.georgsen.dk/hwlab/internal/inventory" "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/printer" "git.georgsen.dk/hwlab/internal/queue" + "git.georgsen.dk/hwlab/internal/usb" ) func main() { @@ -77,8 +79,23 @@ func main() { 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) + go usbManager.Start(ctx) + defer usbManager.Stop() + + // Printer driver — MockDriver until PRT Qutie hardware arrives (2026-04-13). + // TODO(hardware): replace with printer.NewPrtQutieDriver(9600) after characterization. + mockDriver := printer.NewMockDriver() + if err := mockDriver.Connect(); err != nil { + log.Printf("WARNING: mock printer connect: %v", err) + } + inventoryHandler := handlers.NewInventoryHandler(nbClient) - router := api.NewRouter(staticFS, intakeHandler, inventoryHandler) + labelHandler := handlers.NewLabelHandler(nbClient, mockDriver) + usbEventsHandler := handlers.NewUSBEventsHandler(usbManager) + router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) log.Printf("HWLab starting on %s", addr) diff --git a/internal/api/handlers/label.go b/internal/api/handlers/label.go new file mode 100644 index 0000000..1a93e7d --- /dev/null +++ b/internal/api/handlers/label.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "git.georgsen.dk/hwlab/internal/labels" + "git.georgsen.dk/hwlab/internal/netbox" + "git.georgsen.dk/hwlab/internal/printer" +) + +// LabelNetBoxClient is the narrow interface LabelHandler needs from NetBox. +// *netbox.Client satisfies this interface. +type LabelNetBoxClient interface { + GetDevice(ctx context.Context, id int) (*netbox.Device, error) +} + +// LabelPrinter is the narrow interface over printer.PrinterDriver that the +// label handler requires. Allows mock injection in tests. +type LabelPrinter interface { + Print(bitmap []byte, width, height int) error +} + +// LabelHandler handles POST /api/labels/:deviceID/print. +// T-04-09 mitigation: print calls are rate-limited to 1/second per instance +// to prevent runaway label waste. +type LabelHandler struct { + nb LabelNetBoxClient + printer LabelPrinter + + // rate limiting (T-04-09 — DoS / label waste mitigation) + mu sync.Mutex + lastPrintTime time.Time + printCooldown time.Duration +} + +// NewLabelHandler constructs a LabelHandler with a 1-second print cooldown. +func NewLabelHandler(nb LabelNetBoxClient, p LabelPrinter) *LabelHandler { + return &LabelHandler{ + nb: nb, + printer: p, + printCooldown: time.Second, + } +} + +// PrintLabel handles POST /api/labels/:deviceID/print. +// +// Flow: +// 1. Parse deviceID path param +// 2. Rate-limit check (1/second — T-04-09) +// 3. Fetch device from NetBox +// 4. Render label via labels.RenderCable or labels.RenderStandard +// 5. Convert to 1-bit bitmap via printer.ImageToRawBitmap +// 6. Send to printer +// 7. Return 200 {"status":"printed"} +func (h *LabelHandler) PrintLabel(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "deviceID") + id, err := strconv.Atoi(idStr) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + return + } + + // T-04-09: rate limit — reject if last print was less than 1 second ago. + h.mu.Lock() + now := time.Now() + if !h.lastPrintTime.IsZero() && now.Sub(h.lastPrintTime) < h.printCooldown { + h.mu.Unlock() + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "print rate limit: wait 1 second between prints"}) + return + } + h.lastPrintTime = now + h.mu.Unlock() + + device, err := h.nb.GetDevice(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) + return + } + + // Choose renderer based on device type. + var bitmap []byte + var bWidth, bHeight int + + if labels.IsCableDevice(*device) { + d := labels.CableLabelData{ + HWID: device.CustomFields.HWID, + Name: device.Name, + TestDate: device.CustomFields.TestDate, + // USBVersion/MaxSpeedGbps/MaxWatts: parsed from TestData in Phase 5. + // Default values allow label to render without error before that. + USBVersion: "Unknown", + } + labelImg, err := labels.RenderCable(d) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()}) + return + } + bitmap, bWidth, bHeight = printer.ImageToRawBitmap(labelImg) + } else { + d := labels.LabelData{ + HWID: device.CustomFields.HWID, + Name: device.Name, + SpecLine: device.CustomFields.AINotes, + } + labelImg, err := labels.RenderStandard(d) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()}) + return + } + bitmap, bWidth, bHeight = printer.ImageToRawBitmap(labelImg) + } + + if err := h.printer.Print(bitmap, bWidth, bHeight); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "print failed: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "printed"}) +} diff --git a/internal/api/handlers/usb_events.go b/internal/api/handlers/usb_events.go new file mode 100644 index 0000000..4cf094b --- /dev/null +++ b/internal/api/handlers/usb_events.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "git.georgsen.dk/hwlab/internal/usb" +) + +// USBEventSource is the narrow interface over usb.Manager that the SSE handler needs. +// *usb.Manager satisfies this interface. +type USBEventSource interface { + Events() <-chan usb.DeviceEvent +} + +// USBEventsHandler handles GET /api/usb/events — SSE stream of USB device events. +// T-04-11 mitigation: handler returns on r.Context().Done() — no goroutine leak. +type USBEventsHandler struct { + manager USBEventSource +} + +// NewUSBEventsHandler creates a USBEventsHandler backed by the given event source. +func NewUSBEventsHandler(m USBEventSource) *USBEventsHandler { + return &USBEventsHandler{manager: m} +} + +// ServeEvents streams USB device connect/disconnect events as Server-Sent Events. +// +// SSE format (per WHATWG): +// +// data: {"VIDPID":"0525:a4a7","Spec":{...},"State":1}\n\n +// +// A keepalive comment (": keepalive\n\n") is sent every 30 seconds to prevent +// proxy and load-balancer timeouts. +// +// The goroutine exits cleanly when the client disconnects (r.Context().Done()). +func (h *USBEventsHandler) ServeEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // disable nginx proxy buffering + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + // Initial keepalive confirms connection to the client. + fmt.Fprintf(w, ": connected\n\n") + flusher.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + // Client disconnected — T-04-11: return cleanly, no goroutine leak. + return + + case evt, ok := <-h.manager.Events(): + if !ok { + // Channel closed (Manager stopped) — close SSE stream. + return + } + data, err := json.Marshal(evt) + if err != nil { + // Non-fatal: log and continue rather than killing the stream. + fmt.Fprintf(w, ": marshal error\n\n") + flusher.Flush() + continue + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + case <-ticker.C: + // Keepalive comment — prevents proxy timeout on quiet periods. + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 2c8aaf4..26b8ec5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -34,7 +34,15 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // passed from main.go where the go:embed directive lives. // intakeHandler handles POST /api/intake (multipart photo upload). // inventoryHandler handles GET /api/inventory and GET /api/inventory/{id}. -func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler { +// labelHandler handles POST /api/labels/:deviceID/print. +// usbEventsHandler handles GET /api/usb/events (SSE stream). +func NewRouter( + staticFiles fs.FS, + intakeHandler http.Handler, + inventoryHandler *handlers.InventoryHandler, + labelHandler *handlers.LabelHandler, + usbEventsHandler *handlers.USBEventsHandler, +) http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) @@ -45,6 +53,8 @@ func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler * r.Post("/intake", intakeHandler.ServeHTTP) r.Get("/inventory", inventoryHandler.ListInventory) r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) + r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel) + r.Get("/usb/events", usbEventsHandler.ServeEvents) }) // SPA fallback — serve static files; unknown paths fall back to index.html.