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>
363 lines
13 KiB
Markdown
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>&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>&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>
|