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

13 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-usb-manager-label-printing 02 execute 1
internal/labels/renderer.go
internal/labels/renderer_test.go
internal/labels/cable.go
internal/labels/cable_test.go
go.mod
go.sum
true
LBL-01
LBL-02
LBL-03
truths artifacts key_links
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
path provides exports
internal/labels/renderer.go LabelData struct, RenderStandard() and RenderCable() producing image.Image
LabelData
CableLabelData
RenderStandard
RenderCable
path provides exports
internal/labels/cable.go IsCableDevice() helper detecting cable records by tag/type
IsCableDevice
path provides min_lines
internal/labels/renderer_test.go Unit tests: QR URL encoding, image dimensions, pixel non-blank check 60
from to via pattern
internal/labels/renderer.go github.com/skip2/go-qrcode qrcode.Encode(url, qrcode.Low, sizePixels) qrcode.Encode
from to via pattern
internal/labels/renderer.go golang.org/x/image/font/basicfont basicfont.Face7x13 for text rendering 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md @internal/netbox/types.go

From internal/netbox/types.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:

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):

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:

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`:
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.

<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>
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

<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>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md`