homelabby/.planning/phases/04-usb-manager-label-printing/04-02-PLAN.md
Mikkel Georgsen 77bf4ebfd6 docs(04): create phase 4 plans — USB manager, label printing, SSE, intake integration
5 plans across 3 waves covering USB-01 through USB-04 and LBL-01 through LBL-05.
Mock drivers and goroutine-leak harness tests enable full TDD before hardware arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:41:26 +00:00

363 lines
13 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@internal/netbox/types.go
</context>
<interfaces>
<!-- Key contracts the executor needs. -->
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)
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: QR code generation + standard label renderer</name>
<files>internal/labels/renderer.go, internal/labels/renderer_test.go, go.mod, go.sum</files>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -run "TestRenderStandard" -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All 5 behavior tests pass. go.mod includes github.com/skip2/go-qrcode and golang.org/x/image.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Cable label renderer + IsCableDevice detection helper</name>
<files>internal/labels/cable.go, internal/labels/cable_test.go</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/labels/... -v 2>&amp;1 | tail -20</automated>
</verify>
<done>All label tests pass. `go build ./...` succeeds. Both RenderStandard and RenderCable produce valid image.Image values.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md`
</output>