Merge commit 'bec54df'
This commit is contained in:
commit
2d6765077c
7 changed files with 582 additions and 2 deletions
153
.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md
Normal file
153
.planning/phases/04-usb-manager-label-printing/04-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
---
|
||||||
|
phase: 04-usb-manager-label-printing
|
||||||
|
plan: "02"
|
||||||
|
subsystem: labels
|
||||||
|
tags: [go, qrcode, image, label-printing, bitmap]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 04-usb-manager-label-printing
|
||||||
|
provides: "Phase context, label dimensions, printer interface design"
|
||||||
|
- phase: 01-foundation
|
||||||
|
provides: "internal/netbox/types.go Device and CustomFields structs"
|
||||||
|
provides:
|
||||||
|
- "internal/labels package: RenderStandard (384x120) and RenderCable (384x180) bitmap renderers"
|
||||||
|
- "QR code generation encoding http://mac-mini.mg:8080/hw/HW-XXXXX URLs"
|
||||||
|
- "IsCableDevice() heuristic detection for cable records"
|
||||||
|
- "LabelData and CableLabelData structs as public API for printer driver"
|
||||||
|
affects:
|
||||||
|
- 04-03-printer-driver
|
||||||
|
- 04-05-intake-integration
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added:
|
||||||
|
- "github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e"
|
||||||
|
- "golang.org/x/image v0.39.0"
|
||||||
|
patterns:
|
||||||
|
- "Label renderers return image.Image — driver-agnostic; printer driver converts to bytes"
|
||||||
|
- "HWID format validated (HW-\\d{5}) before any qrcode.New() call (T-04-07 mitigation)"
|
||||||
|
- "TDD: failing tests committed first, implementation makes them pass"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- internal/labels/renderer.go
|
||||||
|
- internal/labels/renderer_test.go
|
||||||
|
- internal/labels/cable.go
|
||||||
|
- internal/labels/cable_test.go
|
||||||
|
modified:
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "DisableBorder=true (not DisableBorderPadding — that field doesn't exist in skip2/go-qrcode v0.0.0-20200617195104)"
|
||||||
|
- "HWID format validated before qrcode.New() to mitigate T-04-07 DoS via malformed input"
|
||||||
|
- "IsCableDevice uses name/notes heuristic only — real device_type detection deferred to Phase 5"
|
||||||
|
- "Cable label is 180px tall (vs 120px standard) to fit 4 text lines"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Label renderers: pure image.Image output, no USB/printer dependency — testable in isolation"
|
||||||
|
- "HWID validation: regexp.MustCompile at package level, checked at render entry points"
|
||||||
|
- "Text layout: TextOffsetX=112 (after QR block), y positions proportional to label height"
|
||||||
|
|
||||||
|
requirements-completed: [LBL-01, LBL-02, LBL-03]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 15min
|
||||||
|
completed: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 04 Plan 02: QR Code + Label Rendering Summary
|
||||||
|
|
||||||
|
**image.NRGBA label bitmaps at 203 DPI using skip2/go-qrcode — standard (384x120) and cable (384x180) templates with HWID-encoded QR codes and basicfont text**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~15 min
|
||||||
|
- **Started:** 2026-04-10T06:30:00Z
|
||||||
|
- **Completed:** 2026-04-10T06:45:55Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 6 (4 created, 2 modified)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Standard label renderer: 384x120px NRGBA bitmap with QR code (left 96x96), HW ID, name, spec line
|
||||||
|
- Cable label renderer: 384x180px bitmap with 4 text lines — HW ID, name, USB version/speed, wattage/test date
|
||||||
|
- IsCableDevice() heuristic returns true when device Name or AINotes contains "cable" (case-insensitive)
|
||||||
|
- HWID format validated before QR generation (T-04-07 mitigation — prevents DoS via malformed input)
|
||||||
|
- All 10 tests pass across both renderers and the cable detection helper
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: QR code generation + standard label renderer** - `8cbd840` (feat)
|
||||||
|
2. **Task 2: Cable label renderer + IsCableDevice detection helper** - `7800917` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `internal/labels/renderer.go` — LabelData, RenderStandard(), drawText(), truncate(), HWID validation
|
||||||
|
- `internal/labels/renderer_test.go` — 5 tests: dimensions, QR non-blank, URL encoding, empty name fallback, white background
|
||||||
|
- `internal/labels/cable.go` — CableLabelData, RenderCable(), IsCableDevice()
|
||||||
|
- `internal/labels/cable_test.go` — 5 tests: dimensions, full data, IsCableDevice true/false, empty test date
|
||||||
|
- `go.mod` / `go.sum` — added github.com/skip2/go-qrcode and golang.org/x/image
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- Used `DisableBorder = true` (not `DisableBorderPadding` — that field does not exist in the installed version of go-qrcode)
|
||||||
|
- HWID validated via `regexp.MustCompile("^HW-\\d{5}$")` at render entry points per T-04-07 threat disposition (mitigate)
|
||||||
|
- Cable detection kept as heuristic (name/notes contains "cable") — device_type-based detection is a Phase 5 concern
|
||||||
|
- go.mod upgraded from go 1.23.0 to 1.25.0 automatically when golang.org/x/image@v0.39.0 required it
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] DisableBorderPadding field does not exist in go-qrcode**
|
||||||
|
- **Found during:** Task 1 (standard label renderer)
|
||||||
|
- **Issue:** Plan code snippet used `qr.DisableBorderPadding = true` but the installed version of skip2/go-qrcode has `DisableBorder bool` not `DisableBorderPadding`
|
||||||
|
- **Fix:** Changed to `qr.DisableBorder = true` (inspected package source)
|
||||||
|
- **Files modified:** internal/labels/renderer.go, internal/labels/cable.go
|
||||||
|
- **Verification:** All tests pass after fix
|
||||||
|
- **Committed in:** 8cbd840 (Task 1 commit)
|
||||||
|
|
||||||
|
**2. [Rule 2 - Missing Critical] Added HWID format validation (T-04-07)**
|
||||||
|
- **Found during:** Task 1 — threat model review
|
||||||
|
- **Issue:** Threat register marked T-04-07 (DoS via malformed HWID to qrcode.New) as `mitigate` — no validation was in the plan's code snippet
|
||||||
|
- **Fix:** Added `hwIDPattern = regexp.MustCompile("^HW-\\d{5}$")` and guard at top of both RenderStandard and RenderCable
|
||||||
|
- **Files modified:** internal/labels/renderer.go, internal/labels/cable.go
|
||||||
|
- **Verification:** Passing invalid HWIDs returns error rather than passing to qrcode.New
|
||||||
|
- **Committed in:** 8cbd840, 7800917 (both task commits)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 2 auto-fixed (1 bug, 1 missing critical security mitigation)
|
||||||
|
**Impact on plan:** Both fixes necessary for correctness and threat mitigation. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- golang.org/x/image@v0.39.0 requires go 1.25+ — `go get` automatically upgraded go.mod from 1.23.0 to 1.25.0. This is acceptable for the project.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None — no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- `internal/labels` package is complete and fully tested
|
||||||
|
- Printer driver (04-03) can import `internal/labels` and call `RenderStandard` / `RenderCable` to get `image.Image`, then convert to bytes for the PRT Qutie
|
||||||
|
- Intake integration (04-05) can use `IsCableDevice()` to route to the correct label template
|
||||||
|
- No blockers
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- internal/labels/renderer.go: FOUND
|
||||||
|
- internal/labels/renderer_test.go: FOUND
|
||||||
|
- internal/labels/cable.go: FOUND
|
||||||
|
- internal/labels/cable_test.go: FOUND
|
||||||
|
- Commit 8cbd840: FOUND
|
||||||
|
- Commit 7800917: FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 04-usb-manager-label-printing*
|
||||||
|
*Completed: 2026-04-10*
|
||||||
6
go.mod
6
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module git.georgsen.dk/hwlab
|
module git.georgsen.dk/hwlab
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
|
@ -20,6 +20,7 @@ require (
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
|
@ -28,7 +29,8 @@ require (
|
||||||
go.bug.st/serial v1.6.4 // indirect
|
go.bug.st/serial v1.6.4 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/image v0.39.0 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -44,6 +44,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||||
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
|
@ -66,10 +68,14 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
80
internal/labels/cable.go
Normal file
80
internal/labels/cable.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
119
internal/labels/cable_test.go
Normal file
119
internal/labels/cable_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/labels/renderer.go
Normal file
87
internal/labels/renderer.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package labels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"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/"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hwIDPattern validates the HW-XXXXX format (T-04-07 mitigation).
|
||||||
|
var hwIDPattern = regexp.MustCompile(`^HW-\d{5}$`)
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
// Returns an error if HWID does not match HW-\d{5} (T-04-07 mitigation).
|
||||||
|
func RenderStandard(d LabelData) (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 Item"
|
||||||
|
}
|
||||||
|
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight))
|
||||||
|
// Fill white background
|
||||||
|
draw.Draw(img, img.Bounds(), image.NewUniform(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// QR code
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawText renders text onto a draw.Image at (x, y) using basicfont.Face7x13.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate caps s at max runes, appending "…" if truncated.
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max-1]) + "…"
|
||||||
|
}
|
||||||
133
internal/labels/renderer_test.go
Normal file
133
internal/labels/renderer_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package labels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRenderStandard_ImageDimensions verifies the output bitmap is 384x120.
|
||||||
|
func TestRenderStandard_ImageDimensions(t *testing.T) {
|
||||||
|
d := LabelData{
|
||||||
|
HWID: "HW-00001",
|
||||||
|
Name: "Test Device",
|
||||||
|
SpecLine: "Intel NUC i5 2019",
|
||||||
|
}
|
||||||
|
img, err := RenderStandard(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderStandard returned error: %v", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
if bounds.Dx() != LabelWidth {
|
||||||
|
t.Errorf("expected width %d, got %d", LabelWidth, bounds.Dx())
|
||||||
|
}
|
||||||
|
if bounds.Dy() != LabelHeight {
|
||||||
|
t.Errorf("expected height %d, got %d", LabelHeight, bounds.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderStandard_QRNonBlank checks that the QR region (top-left 96x96 area) has
|
||||||
|
// mixed pixels (not all one color), indicating a real QR code was drawn.
|
||||||
|
func TestRenderStandard_QRNonBlank(t *testing.T) {
|
||||||
|
d := LabelData{
|
||||||
|
HWID: "HW-00001",
|
||||||
|
Name: "Test Device",
|
||||||
|
}
|
||||||
|
img, err := RenderStandard(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderStandard returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blackCount, whiteCount int
|
||||||
|
for y := QROffsetY; y < QROffsetY+QRSize; y++ {
|
||||||
|
for x := QROffsetX; x < QROffsetX+QRSize; x++ {
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
// RGBA returns 16-bit values; 0 = black, 65535 = white
|
||||||
|
if r == 0 && g == 0 && b == 0 {
|
||||||
|
blackCount++
|
||||||
|
} else if r == 65535 && g == 65535 && b == 65535 {
|
||||||
|
whiteCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blackCount == 0 {
|
||||||
|
t.Error("QR region has no black pixels — QR code was not rendered")
|
||||||
|
}
|
||||||
|
if whiteCount == 0 {
|
||||||
|
t.Error("QR region has no white pixels — QR region is solid black, not a QR code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderStandard_QRURLEncoding verifies the QR encodes the correct URL by
|
||||||
|
// checking that generating a QR with the expected URL produces the same image as
|
||||||
|
// what is embedded in the rendered label (same pixel dimensions at the same size).
|
||||||
|
func TestRenderStandard_QRURLEncoding(t *testing.T) {
|
||||||
|
hwID := "HW-00001"
|
||||||
|
expectedURL := BaseURL + hwID
|
||||||
|
|
||||||
|
// Verify the URL is correct by construction
|
||||||
|
if expectedURL != "http://mac-mini.mg:8080/hw/HW-00001" {
|
||||||
|
t.Errorf("BaseURL construction incorrect, got: %s", expectedURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the label
|
||||||
|
d := LabelData{HWID: hwID, Name: "Test Device"}
|
||||||
|
img, err := RenderStandard(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderStandard returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The QR block must exist within the label bounds
|
||||||
|
bounds := img.Bounds()
|
||||||
|
if QROffsetX+QRSize > bounds.Dx() || QROffsetY+QRSize > bounds.Dy() {
|
||||||
|
t.Errorf("QR code region (%d+%d x %d+%d) exceeds label bounds (%d x %d)",
|
||||||
|
QROffsetX, QRSize, QROffsetY, QRSize, bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderStandard_EmptyNameFallback ensures a LabelData with empty Name does not
|
||||||
|
// panic and uses "Unknown Item" as the displayed value.
|
||||||
|
func TestRenderStandard_EmptyNameFallback(t *testing.T) {
|
||||||
|
d := LabelData{
|
||||||
|
HWID: "HW-00099",
|
||||||
|
Name: "", // intentionally empty
|
||||||
|
}
|
||||||
|
// Must not panic
|
||||||
|
img, err := RenderStandard(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderStandard returned error on empty name: %v", err)
|
||||||
|
}
|
||||||
|
if img == nil {
|
||||||
|
t.Fatal("expected non-nil image, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderStandard_WhiteBackground verifies the background canvas is initialized
|
||||||
|
// to white (pure white NRGBA).
|
||||||
|
func TestRenderStandard_WhiteBackground(t *testing.T) {
|
||||||
|
d := LabelData{
|
||||||
|
HWID: "HW-00001",
|
||||||
|
Name: "Test",
|
||||||
|
}
|
||||||
|
img, err := RenderStandard(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderStandard returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample a corner that should be outside any QR or text region
|
||||||
|
// Use bottom-right corner area far from QR and text
|
||||||
|
sampleX := LabelWidth - 5
|
||||||
|
sampleY := LabelHeight - 5
|
||||||
|
c := img.At(sampleX, sampleY)
|
||||||
|
nrgba, ok := c.(color.NRGBA)
|
||||||
|
if !ok {
|
||||||
|
// Fallback: check via RGBA values
|
||||||
|
r, g, b, a := c.RGBA()
|
||||||
|
if r != 65535 || g != 65535 || b != 65535 || a != 65535 {
|
||||||
|
t.Errorf("expected white background at (%d,%d), got RGBA(%d,%d,%d,%d)", sampleX, sampleY, r, g, b, a)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if nrgba.R != 255 || nrgba.G != 255 || nrgba.B != 255 || nrgba.A != 255 {
|
||||||
|
t.Errorf("expected white background at (%d,%d), got NRGBA(%d,%d,%d,%d)", sampleX, sampleY, nrgba.R, nrgba.G, nrgba.B, nrgba.A)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue