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/config"
|
||||||
"git.georgsen.dk/hwlab/internal/inventory"
|
"git.georgsen.dk/hwlab/internal/inventory"
|
||||||
"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"
|
||||||
|
"git.georgsen.dk/hwlab/internal/usb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -77,8 +79,23 @@ func main() {
|
||||||
cfg.AI.QuickAddThreshold,
|
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)
|
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)
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
log.Printf("HWLab starting on %s", addr)
|
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.
|
// passed from main.go where the go:embed directive lives.
|
||||||
// intakeHandler handles POST /api/intake (multipart photo upload).
|
// intakeHandler handles POST /api/intake (multipart photo upload).
|
||||||
// inventoryHandler handles GET /api/inventory and GET /api/inventory/{id}.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
@ -45,6 +53,8 @@ func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *
|
||||||
r.Post("/intake", intakeHandler.ServeHTTP)
|
r.Post("/intake", intakeHandler.ServeHTTP)
|
||||||
r.Get("/inventory", inventoryHandler.ListInventory)
|
r.Get("/inventory", inventoryHandler.ListInventory)
|
||||||
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
|
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.
|
// SPA fallback — serve static files; unknown paths fall back to index.html.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue