package labels import ( "fmt" "image" "image/color" "image/draw" "strings" "github.com/skip2/go-qrcode" "git.georgsen.dk/hwlab/internal/netbox" ) const CableLabelHeight = 180 // CableLabelData holds fields for the cable-specific label template (per LBL-03). type CableLabelData struct { HWID string // e.g. "HW-00042" Name string // e.g. "USB-C 2m Cable" USBVersion string // e.g. "USB 3.2 Gen 2" MaxSpeedGbps float64 // e.g. 10.0 MaxWatts int // e.g. 100 TestDate string // ISO 8601 or "" for "Not tested" } // RenderCable produces a 384x180 label bitmap for a cable item. // Layout: QR code left (96x96), text right with 4 lines: // // Line 1: HW ID // Line 2: Name (truncated) // Line 3: USB version + speed (e.g. "USB 3.2 Gen 2 / 10 Gbps") // Line 4: Max power + test date (e.g. "100W Tested: 2026-04-13") // // Returns an error if HWID does not match HW-\d{5} (T-04-07 mitigation). func RenderCable(d CableLabelData) (image.Image, error) { if !hwIDPattern.MatchString(d.HWID) { return nil, fmt.Errorf("labels: invalid HWID %q: must match HW-NNNNN", d.HWID) } if d.Name == "" { d.Name = "Unknown Cable" } testDateStr := d.TestDate if testDateStr == "" { testDateStr = "Not tested" } img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, CableLabelHeight)) draw.Draw(img, img.Bounds(), image.NewUniform(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), image.Point{}, draw.Src) // QR code — vertically centered in 180px height qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low) if err != nil { return nil, fmt.Errorf("labels: QR generation failed: %w", err) } qr.DisableBorder = true qrImg := qr.Image(QRSize) qrOffsetY := (CableLabelHeight - QRSize) / 2 qrDst := image.Rect(QROffsetX, qrOffsetY, QROffsetX+QRSize, qrOffsetY+QRSize) draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over) // Text lines (4 lines across 180px height) drawText(img, TextOffsetX, 30, d.HWID) drawText(img, TextOffsetX, 62, truncate(d.Name, 30)) speedLine := fmt.Sprintf("%s / %.0f Gbps", d.USBVersion, d.MaxSpeedGbps) drawText(img, TextOffsetX, 94, truncate(speedLine, 30)) powerLine := fmt.Sprintf("%dW Tested: %s", d.MaxWatts, testDateStr) drawText(img, TextOffsetX, 126, truncate(powerLine, 30)) return img, nil } // IsCableDevice returns true if the NetBox device record represents a cable. // Detection: Name contains "cable" (case-insensitive) OR AINotes contains "cable". // This is a heuristic — real cable detection uses device_type in Phase 5. func IsCableDevice(d netbox.Device) bool { nameLower := strings.ToLower(d.Name) notesLower := strings.ToLower(d.CustomFields.AINotes) return strings.Contains(nameLower, "cable") || strings.Contains(notesLower, "cable") }