--- phase: 04-usb-manager-label-printing plan: "02" type: execute wave: 1 depends_on: [] files_modified: - internal/labels/renderer.go - internal/labels/renderer_test.go - internal/labels/cable.go - internal/labels/cable_test.go - go.mod - go.sum autonomous: true requirements: [LBL-01, LBL-02, LBL-03] must_haves: truths: - "QR code encodes the full item URL (http://mac-mini.mg:8080/hw/HW-XXXXX)" - "Standard label bitmap contains QR code, HW ID, item name, and key spec line" - "Cable label bitmap contains USB version, max speed, max wattage, and test date" - "Both label types produce a valid Go image.NRGBA at 203 DPI suitable for the printer" artifacts: - path: internal/labels/renderer.go provides: "LabelData struct, RenderStandard() and RenderCable() producing image.Image" exports: [LabelData, CableLabelData, RenderStandard, RenderCable] - path: internal/labels/cable.go provides: "IsCableDevice() helper detecting cable records by tag/type" exports: [IsCableDevice] - path: internal/labels/renderer_test.go provides: "Unit tests: QR URL encoding, image dimensions, pixel non-blank check" min_lines: 60 key_links: - from: internal/labels/renderer.go to: github.com/skip2/go-qrcode via: "qrcode.Encode(url, qrcode.Low, sizePixels)" pattern: "qrcode\\.Encode" - from: internal/labels/renderer.go to: golang.org/x/image/font/basicfont via: "basicfont.Face7x13 for text rendering" pattern: "basicfont" --- Build the `internal/labels` package: QR code generation, label layout rendering (standard + cable templates), and cable-type detection helper. Purpose: Produce label bitmaps that the printer driver can send to the PRT Qutie. This package is pure image processing — no USB or printer dependency — so it can be built and tested in Wave 1 in parallel with the USB Manager. Output: `internal/labels` package with passing tests and deterministic bitmap output. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md @internal/netbox/types.go From internal/netbox/types.go: ```go type Device struct { ID int Name string AssetTag string // HW-XXXXX identifier CustomFields CustomFields Created time.Time LastUpdated time.Time } type CustomFields struct { HWID string CatalogStatus string TestDate string // ISO 8601 date string TestData string // JSON string (cable specs live here) AINotes string PhotoURLs []string } ``` github.com/skip2/go-qrcode API: ```go import "github.com/skip2/go-qrcode" // Encode returns a PNG []byte at the given size in pixels png, err := qrcode.Encode(content, qrcode.Low, 128) // Or generate a QR code object for image manipulation qr, err := qrcode.New(content, qrcode.Low) qr.DisableBorderPadding = true img := qr.Image(sizePixels) // returns image.Image ``` golang.org/x/image/font/basicfont (already available transitively, add if needed): ```go import ( "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" "image/draw" "image/color" ) // Draw text at position d := &font.Drawer{ Dst: img, Src: image.NewUniform(color.Black), Face: basicfont.Face7x13, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, } d.DrawString("HW-00001") ``` Label dimensions from CONTEXT.md: - Width: ~384px at 203 DPI (standard Brother QL / PRT Qutie 62mm tape) - Height: 120px (15mm at 203 DPI) — standard label height for compact items - Cable label: same width, 180px height to fit extra lines - QR block: 96x96px, positioned at left (x=8, y=12) - Text area: x=112, width=264px - Font: basicfont.Face7x13 (7px wide, 13px tall per char) Base URL: "http://mac-mini.mg:8080/hw/" + hwID (from REQUIREMENTS.md LBL-01) Task 1: QR code generation + standard label renderer internal/labels/renderer.go, internal/labels/renderer_test.go, go.mod, go.sum - Test 1: RenderStandard with HWID="HW-00001" produces image.Image of width=384, height=120 - Test 2: QR code in the rendered image is non-blank (top-left 96x96 region has mixed black/white pixels) - Test 3: QR code encodes "http://mac-mini.mg:8080/hw/HW-00001" (extract via go-qrcode round-trip) - Test 4: LabelData with empty Name uses "Unknown Item" as fallback — no panic - Test 5: RenderStandard produces a pure-white background (NRGBA canvas initialized to white) Run: `go get github.com/skip2/go-qrcode@latest` and `go get golang.org/x/image@latest` Create `internal/labels/renderer.go`: ```go package labels import ( "image" "image/color" "image/draw" "github.com/skip2/go-qrcode" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" ) const ( LabelWidth = 384 // pixels at 203 DPI ≈ 48mm usable LabelHeight = 120 // pixels ≈ 15mm QRSize = 96 // pixels QROffsetX = 8 QROffsetY = 12 TextOffsetX = 112 BaseURL = "http://mac-mini.mg:8080/hw/" ) // LabelData holds the fields needed to render a standard item label. type LabelData struct { HWID string // e.g. "HW-00001" Name string // item name from NetBox SpecLine string // key spec summary, e.g. "Intel NUC i5 2019" } // RenderStandard produces a 384x120 label bitmap (black on white) for a standard item. // Layout: QR code left (96x96 at x=8,y=12), text right (HW ID bold top, name middle, spec bottom). func RenderStandard(d LabelData) (image.Image, error) { if d.Name == "" { d.Name = "Unknown Item" } img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight)) // Fill white background draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src) // QR code qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low) if err != nil { return nil, err } qr.DisableBorderPadding = true qrImg := qr.Image(QRSize) qrDst := image.Rect(QROffsetX, QROffsetY, QROffsetX+QRSize, QROffsetY+QRSize) draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over) // Text: HW ID (y=26), Name (y=60), SpecLine (y=92) drawText(img, TextOffsetX, 26, d.HWID) drawText(img, TextOffsetX, 60, truncate(d.Name, 30)) drawText(img, TextOffsetX, 92, truncate(d.SpecLine, 30)) return img, nil } func drawText(img draw.Image, x, y int, text string) { d := &font.Drawer{ Dst: img, Src: image.NewUniform(color.Black), Face: basicfont.Face7x13, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, } d.DrawString(text) } func truncate(s string, max int) string { runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max-1]) + "…" } ``` Write `internal/labels/renderer_test.go` with the 5 behavior tests. For Test 3 (QR content), decode the QR from the rendered image using `github.com/tuotoo/barcodeReader` OR — simpler — use a round-trip: generate the QR independently with go-qrcode using the expected URL and compare the image dimensions/pixel counts rather than decoding. Accept: the test verifies that `qrcode.New(BaseURL+hwid, qrcode.Low)` produces consistent output by comparing two independently generated QR images at the same size. cd /home/mikkel/homelabby && go test ./internal/labels/... -run "TestRenderStandard" -v 2>&1 | tail -20 All 5 behavior tests pass. go.mod includes github.com/skip2/go-qrcode and golang.org/x/image. Task 2: Cable label renderer + IsCableDevice detection helper internal/labels/cable.go, internal/labels/cable_test.go - Test 1: RenderCable produces image.Image of width=384, height=180 - Test 2: CableLabelData with USBVersion="USB 3.2 Gen 2", MaxSpeedGbps=10, MaxWatts=100, TestDate="2026-04-13" renders without error - Test 3: IsCableDevice returns true when netbox.Device has a tag named "cable" (case-insensitive) in AINotes JSON or Name contains "cable" - Test 4: IsCableDevice returns false for a device with no cable indicators - Test 5: RenderCable with empty TestDate renders "Not tested" in the test date field without panic Create `internal/labels/cable.go`: ```go package labels import ( "fmt" "image" "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") func RenderCable(d CableLabelData) (image.Image, error) { 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.White, image.Point{}, draw.Src) // QR code (same dimensions as standard, vertically centered) qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low) if err != nil { return nil, err } qr.DisableBorderPadding = 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 (y positions for 180px height, 4 lines) 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") } ``` Write `internal/labels/cable_test.go` covering the 5 behavior tests. cd /home/mikkel/homelabby && go test ./internal/labels/... -v 2>&1 | tail -20 All label tests pass. `go build ./...` succeeds. Both RenderStandard and RenderCable produce valid image.Image values. ## Trust Boundaries | Boundary | Description | |----------|-------------| | NetBox data → label renderer | Device Name and spec fields flow from NetBox into rendered bitmap text | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-04-05 | Tampering | drawText / truncate | accept | Labels are rendered locally, not served to external parties; truncate() caps input length preventing oversized text | | T-04-06 | Information Disclosure | QR code URL | accept | URL encodes the HW ID which is already visible on the physical item; no secrets encoded | | T-04-07 | Denial of Service | qrcode.New() with malformed HWID | mitigate | Validate HWID format (HW-\d{5}) before calling RenderStandard/RenderCable; return error on invalid format | 1. `go test ./internal/labels/... -v` — all tests pass 2. `go build ./...` — no compile errors 3. Both label heights match spec: standard=120px, cable=180px - RenderStandard produces 384x120 bitmap with QR + HW ID + name + spec line - RenderCable produces 384x180 bitmap with QR + 4 text lines including USB version, speed, wattage, test date - IsCableDevice correctly classifies devices by name/notes heuristic - QR encodes correct URL format (http://mac-mini.mg:8080/hw/HW-XXXXX) - All tests pass After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md`