feat(04-03): LabelHandler, USBEventsHandler, router wiring, main.go USB+printer init

- LabelHandler: POST /api/labels/:deviceID/print with 1/s rate limit (T-04-09)
- USBEventsHandler: GET /api/usb/events SSE stream, exits on context cancel (T-04-11)
- router.go: two new parameters + routes wired
- main.go: USB Manager started with ctx, MockDriver connected, handlers passed to router
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:52:52 +00:00
parent dd381eefa3
commit 9f57cbdf6c
4 changed files with 239 additions and 2 deletions

View file

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

View file

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

View file

@ -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()
}
}
}

View file

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