diff --git a/internal/labels/cable.go b/internal/labels/cable.go new file mode 100644 index 0000000..d164c74 --- /dev/null +++ b/internal/labels/cable.go @@ -0,0 +1,80 @@ +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") +} diff --git a/internal/labels/cable_test.go b/internal/labels/cable_test.go new file mode 100644 index 0000000..ae4ab07 --- /dev/null +++ b/internal/labels/cable_test.go @@ -0,0 +1,119 @@ +package labels + +import ( + "testing" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +// TestRenderCable_ImageDimensions verifies cable label is 384x180. +func TestRenderCable_ImageDimensions(t *testing.T) { + d := CableLabelData{ + HWID: "HW-00042", + Name: "USB-C 2m Cable", + USBVersion: "USB 3.2 Gen 2", + MaxSpeedGbps: 10, + MaxWatts: 100, + TestDate: "2026-04-13", + } + img, err := RenderCable(d) + if err != nil { + t.Fatalf("RenderCable returned error: %v", err) + } + bounds := img.Bounds() + if bounds.Dx() != LabelWidth { + t.Errorf("expected width %d, got %d", LabelWidth, bounds.Dx()) + } + if bounds.Dy() != CableLabelHeight { + t.Errorf("expected height %d, got %d", CableLabelHeight, bounds.Dy()) + } +} + +// TestRenderCable_FullData verifies a fully-populated CableLabelData renders without error. +func TestRenderCable_FullData(t *testing.T) { + d := CableLabelData{ + HWID: "HW-00042", + Name: "USB-C 2m Cable", + USBVersion: "USB 3.2 Gen 2", + MaxSpeedGbps: 10, + MaxWatts: 100, + TestDate: "2026-04-13", + } + img, err := RenderCable(d) + if err != nil { + t.Fatalf("RenderCable returned error: %v", err) + } + if img == nil { + t.Fatal("expected non-nil image") + } +} + +// TestIsCableDevice_True verifies that a device with "cable" in Name returns true. +func TestIsCableDevice_True(t *testing.T) { + tests := []struct { + name string + device netbox.Device + }{ + { + name: "cable in device name", + device: netbox.Device{ + Name: "USB-C Cable 2m", + }, + }, + { + name: "cable in AINotes", + device: netbox.Device{ + Name: "HW-00050", + CustomFields: netbox.CustomFields{ + AINotes: "This is a USB cable for data transfer", + }, + }, + }, + { + name: "CABLE uppercase", + device: netbox.Device{ + Name: "CABLE USB 3.0", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !IsCableDevice(tt.device) { + t.Errorf("expected IsCableDevice to return true for device: %+v", tt.device) + } + }) + } +} + +// TestIsCableDevice_False verifies that a device with no cable indicators returns false. +func TestIsCableDevice_False(t *testing.T) { + d := netbox.Device{ + Name: "Intel NUC i5", + CustomFields: netbox.CustomFields{ + AINotes: "Mini PC with 16GB RAM", + }, + } + if IsCableDevice(d) { + t.Errorf("expected IsCableDevice to return false for non-cable device: %+v", d) + } +} + +// TestRenderCable_EmptyTestDate verifies "Not tested" is used when TestDate is empty. +func TestRenderCable_EmptyTestDate(t *testing.T) { + d := CableLabelData{ + HWID: "HW-00042", + Name: "USB-C Cable", + USBVersion: "USB 2.0", + MaxSpeedGbps: 0.48, + MaxWatts: 5, + TestDate: "", // intentionally empty + } + // Must not panic + img, err := RenderCable(d) + if err != nil { + t.Fatalf("RenderCable returned error on empty TestDate: %v", err) + } + if img == nil { + t.Fatal("expected non-nil image, got nil") + } +}