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:
parent
dd381eefa3
commit
9f57cbdf6c
4 changed files with 239 additions and 2 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
125
internal/api/handlers/label.go
Normal file
125
internal/api/handlers/label.go
Normal 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"})
|
||||
}
|
||||
85
internal/api/handlers/usb_events.go
Normal file
85
internal/api/handlers/usb_events.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue