From 82d8fe6c55b67c22240233d1140028d8ff27b6e2 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 20 May 2026 00:25:01 +0200 Subject: [PATCH 1/5] harness-niimbot: re-include in workspace, restore from cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds the harness-niimbot app to the workspace after the Wave 4 0.6.0 release cut. Niimbot remains unpublished (0.1.0 in sibling repo), so it needs link-overrides to ../niimbot/packages/{core,web} in the harness root — the only sibling-linked driver in the harness today. Changes: - pnpm-workspace.yaml: drop the !apps/harness-niimbot exclusion. - eslint.config.js: drop the apps/harness-niimbot/** ignore block. - package.json: add niimbot-core/web link overrides. - apps/harness-niimbot/package.json: bump niimbot-core/web spec from ^0.0.0 → ^0.1.0 to match the sibling versions. - apps/harness-niimbot/src/adapter.ts: drop stale 'reporter' from buildReport destructure (BuildReportInput no longer carries it) and drop the unnecessary device type assertion / unused param. Typecheck, lint, and test pass across all five harness apps. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/harness-niimbot/env.d.ts | 9 + apps/harness-niimbot/index.html | 13 + apps/harness-niimbot/package.json | 39 +++ apps/harness-niimbot/src/adapter.ts | 246 +++++++++++++++++++ apps/harness-niimbot/src/diagnostic-print.ts | 61 +++++ apps/harness-niimbot/src/main.ts | 5 + apps/harness-niimbot/src/transport/mock.ts | 178 ++++++++++++++ apps/harness-niimbot/src/version.ts | 10 + apps/harness-niimbot/tsconfig.json | 20 ++ apps/harness-niimbot/vite.config.ts | 33 +++ eslint.config.js | 4 - package.json | 2 + pnpm-lock.yaml | 60 +++++ pnpm-workspace.yaml | 3 - 14 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 apps/harness-niimbot/env.d.ts create mode 100644 apps/harness-niimbot/index.html create mode 100644 apps/harness-niimbot/package.json create mode 100644 apps/harness-niimbot/src/adapter.ts create mode 100644 apps/harness-niimbot/src/diagnostic-print.ts create mode 100644 apps/harness-niimbot/src/main.ts create mode 100644 apps/harness-niimbot/src/transport/mock.ts create mode 100644 apps/harness-niimbot/src/version.ts create mode 100644 apps/harness-niimbot/tsconfig.json create mode 100644 apps/harness-niimbot/vite.config.ts diff --git a/apps/harness-niimbot/env.d.ts b/apps/harness-niimbot/env.d.ts new file mode 100644 index 0000000..15bc836 --- /dev/null +++ b/apps/harness-niimbot/env.d.ts @@ -0,0 +1,9 @@ +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component: DefineComponent, Record, any>; + export default component; +} diff --git a/apps/harness-niimbot/index.html b/apps/harness-niimbot/index.html new file mode 100644 index 0000000..9b34b45 --- /dev/null +++ b/apps/harness-niimbot/index.html @@ -0,0 +1,13 @@ + + + + + + + thermal-label · niimbot harness + + +
+ + + diff --git a/apps/harness-niimbot/package.json b/apps/harness-niimbot/package.json new file mode 100644 index 0000000..ee06e68 --- /dev/null +++ b/apps/harness-niimbot/package.json @@ -0,0 +1,39 @@ +{ + "name": "@thermal-label/harness-niimbot", + "version": "0.0.0", + "private": true, + "description": "Browser-hosted niimbot hardware-reporting harness. Single-page guided UX, Web Bluetooth connect, diagnostic-print, prefilled GitHub-issue submit. Workspace-internal — released only as zipped static-bundle GitHub Release artifacts.", + "type": "module", + "license": "MIT", + "author": "Mannes Brak", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "test": "vitest run --passWithNoTests", + "lint": "eslint src" + }, + "dependencies": { + "@mbtech-nl/bitmap": "^1.3.0", + "@thermal-label/contracts": "^0.6.0", + "@thermal-label/harness-components": "workspace:*", + "@thermal-label/harness-core": "workspace:*", + "@thermal-label/harness-shell": "workspace:*", + "@thermal-label/niimbot-core": "^0.1.0", + "@thermal-label/niimbot-web": "^0.1.0", + "@thermal-label/transport": "^0.5.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@mbtech-nl/eslint-config": "^1.1.0", + "@mbtech-nl/tsconfig": "^1.1.0", + "@types/node": "^22.0.0", + "@types/web-bluetooth": "^0.0.20", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/apps/harness-niimbot/src/adapter.ts b/apps/harness-niimbot/src/adapter.ts new file mode 100644 index 0000000..f35842d --- /dev/null +++ b/apps/harness-niimbot/src/adapter.ts @@ -0,0 +1,246 @@ +/** + * Niimbot `DriverAdapter` — wires niimbot-core + niimbot-web into + * the shared harness shell. + * + * Multi-device, single-engine driver. The registry today carries + * D11 / D110 / B1 / B21 (and variants) — every chassis declares + * exactly one engine. The harness-shell hides the engine-tabs strip + * when `device.engines.length <= 1`, so no per-engine routing. + * + * Transport: every niimbot model declares `bluetooth-gatt` (universal + * service UUID `e7810a71-…` + per-chassis name prefix). Some chassis + * also declare `serial` / `bluetooth-spp` for legacy paths; those + * surface as additional buttons via plan-11 ``. + * USB is NOT in the registry today (DECISIONS.md § D8 — revisit + * pending B1 USB Printer Class evidence; see niimbot/B1_USB_DEBUG_HANDOFF.md). + * + * Connect path: + * - Real: `requestPrinters({ transport, deviceKey? })`. If `deviceKey` + * is omitted, niimbot-web throws `DeviceIdentificationRequiredError` + * and the shell renders `` with the registry + * candidates filtered to the picked transport. + * - Mock: `new NiimbotPrinter(DEVICES.B1, MockTransport.open())` + * wrapped in the per-engine map. The mock transport replies to + * every framed packet with the matching `In_*` ack and returns a + * pre-completed `PrintStatus` so the strategy resolves instantly + * during a self-walk. + */ +import type { DriverAdapter, MockSpec } from '@thermal-label/harness-shell'; +import type { + HardwareReport, + IdentitySnapshot, + TransportReport, +} from '@thermal-label/harness-core/shared'; +import { + DEVICES, + ALL_MEDIA, + NiimbotPrinter, + type NiimbotDevice, + type NiimbotMedia, +} from '@thermal-label/niimbot-core'; +import { requestPrinters } from '@thermal-label/niimbot-web'; +import type { MediaGroupKey, MediaSwatch } from '@thermal-label/harness-components/types'; +import { MockTransport, type MockTarget } from './transport/mock'; +import { buildDiagnosticImage } from './diagnostic-print'; +import { HARNESS_VERSION, DRIVER_VERSION } from './version'; + +const DRIVER_KEY = 'niimbot'; +const TARGET_REPO = 'thermal-label/niimbot'; + +// ─── Mock targets ──────────────────────────────────────────────── + +const MOCK_TARGETS: Record = { + b1: { + displayName: 'Niimbot B1', + deviceKey: 'B1', + aliases: ['b1', 'niimbot-b1'], + }, +}; + +function buildMockTargets(): Record> { + const out: Record> = {}; + for (const [key, meta] of Object.entries(MOCK_TARGETS)) { + const device = (DEVICES as Record)[meta.deviceKey]; + if (!device) { + throw new Error( + `Mock target ${key} → device ${meta.deviceKey} not found in DEVICES registry — fix mock target table.`, + ); + } + const gatt = device.transports['bluetooth-gatt']; + if (!gatt) { + throw new Error( + `Mock target ${key} → device ${meta.deviceKey} has no bluetooth-gatt transport — registry malformed.`, + ); + } + out[key] = { + displayName: meta.displayName, + device, + transport: 'bluetooth-gatt', + filter: { + serviceUuid: gatt.serviceUuid, + ...(gatt.namePrefix !== undefined ? { namePrefix: gatt.namePrefix } : {}), + }, + aliases: meta.aliases, + }; + } + return out; +} + +// ─── Media-picker bindings ─────────────────────────────────────── + +/** + * Map a chassis to the substrate-tag(s) niimbot media uses on + * `targetModels`. The registry's `NiimbotTargetModel` is a substrate + * family (`niimbot-d-series`, `niimbot-b-series`, plus the special + * cases `niimbot-b3s` and `niimbot-b4`), NOT a device key — so we + * derive the right tags from the chassis key here. + */ +function targetTagsForDevice(device: NiimbotDevice): readonly NiimbotMedia['targetModels'][number][] { + const k = device.key; + if (k === 'B3S') return ['niimbot-b3s', 'niimbot-b-series']; + if (k === 'B4') return ['niimbot-b4', 'niimbot-b-series']; + if (k.startsWith('B')) return ['niimbot-b-series']; + if (k.startsWith('D')) return ['niimbot-d-series']; + return []; +} + +function filterByDeviceEngine( + media: readonly NiimbotMedia[], + device: NiimbotDevice, +): readonly NiimbotMedia[] { + const tags = targetTagsForDevice(device); + if (tags.length === 0) return media; + return media.filter(m => m.targetModels.some(t => tags.includes(t))); +} + +function groupBy(m: NiimbotMedia): MediaGroupKey { + // Group by media kind: continuous vs die-cut. Continuous rolls + // sort first (no per-label feed step) so the operator sees them + // alongside the die-cut catalogue. + if (m.type === 'continuous') { + return { key: 'continuous', label: 'Continuous rolls', priority: 'primary', sort: 0 }; + } + return { key: 'die-cut', label: 'Die-cut labels', priority: 'primary', sort: 1 }; +} + +function swatch(): MediaSwatch | null { + // Every niimbot media is monochrome thermal — no per-cassette + // colour to render. Return null so the swatch column collapses. + return null; +} + +// ─── DriverAdapter ─────────────────────────────────────────────── + +export const adapter: DriverAdapter = { + driverKey: DRIVER_KEY, + driverDisplayName: 'Niimbot', + targetRepo: TARGET_REPO, + harnessVersion: HARNESS_VERSION, + driverVersion: DRIVER_VERSION, + + devices: Object.values(DEVICES), + media: ALL_MEDIA as readonly NiimbotMedia[], + deviceKey: d => d.key, + deviceName: d => d.name, + + connect: async opts => { + if (opts.mock && opts.mockTarget) { + const target = opts.mockTarget as MockTarget; + MockTransport.identityFor(target); + const meta = MOCK_TARGETS[target]; + const device = (DEVICES as Record)[meta.deviceKey]; + if (!device) throw new Error(`Unknown mock target ${target}`); + const engine = device.engines[0]; + if (!engine) throw new Error(`Niimbot device ${device.key} has no engines — registry malformed.`); + const transport = MockTransport.open(target); + const printer = new NiimbotPrinter(device, transport); + return { + printers: { [engine.role]: printer }, + device, + mocked: true, + }; + } + + // Real connect — niimbot-web's unified factory throws + // `DeviceIdentificationRequiredError` when `deviceKey` is omitted; + // the shell catches it and renders `` filtered to + // candidates declaring the picked transport. The error's + // `continueWith(deviceKey)` reuses the originally-opened picker. + const transport = opts.transport ?? 'bluetooth-gatt'; + const printers = await requestPrinters({ transport }); + const roles = Object.keys(printers); + const firstRole = roles[0]; + if (firstRole === undefined) { + throw new Error('Niimbot connect returned no printers — driver-web factory bug.'); + } + const printer = printers[firstRole]; + if (!(printer instanceof NiimbotPrinter)) { + throw new Error( + `Niimbot connect returned non-NiimbotPrinter for role "${firstRole}" — driver-web factory bug.`, + ); + } + return { + printers, + device: printer.device, + mocked: false, + }; + }, + + mockTargets: buildMockTargets(), + defaultMockTarget: 'b1', + + mediaPicker: { + filterByDeviceEngine, + groupBy, + swatch, + describe: m => m.name, + }, + + buildDiagnosticImage: ({ device, media, harnessVersion, driverVersion }) => + buildDiagnosticImage({ device, media, harnessVersion, driverVersion }), + + buildReport: ({ device, identity, primarySession, mocked }) => { + if (primarySession.rung === null || primarySession.media === null) { + throw new Error('buildReport: primary session must have rung and media set.'); + } + const gatt = device.transports['bluetooth-gatt']; + const transportReport: TransportReport = { + name: 'bluetooth-gatt', + patterns: { diagnostic: 'pass' }, + rung: primarySession.rung, + ...(primarySession.notes.trim() ? { notes: primarySession.notes.trim() } : {}), + }; + // Surface the GATT service UUID on `detected` so triage always + // sees a service identifier — same fallback pattern as the + // letratag adapter (plan 11 §IdentitySnapshot.serviceUuid). + const detected: IdentitySnapshot = { + ...identity, + ...(identity.serviceUuid === undefined && gatt + ? { serviceUuid: gatt.serviceUuid } + : {}), + extra: { ...identity.extra, ...(mocked ? { mocked: true } : {}) }, + }; + const media = primarySession.media; + const report: HardwareReport = { + schemaVersion: 1, + driver: DRIVER_KEY, + driverVersion: DRIVER_VERSION, + harnessVersion: HARNESS_VERSION, + device: { + detected, + confirmed: { + model: device.name, + // Niimbot has no fixed VID/PID in the BLE-only path. The + // B1 USB chassis (VID 3513 / PID 0002) IS in the wild but + // not declared in the registry yet (DECISIONS.md § D8). + overrides: { + media: String(media.id), + }, + }, + }, + transports: [transportReport], + submittedAt: new Date().toISOString(), + }; + return report; + }, +}; diff --git a/apps/harness-niimbot/src/diagnostic-print.ts b/apps/harness-niimbot/src/diagnostic-print.ts new file mode 100644 index 0000000..d26952e --- /dev/null +++ b/apps/harness-niimbot/src/diagnostic-print.ts @@ -0,0 +1,61 @@ +/** + * Niimbot diagnostic-image builder for the browser harness. + * + * Niimbot is a multi-engine driver family — head widths range from + * 96 dots (D11/D110, 12 mm) to 591 dots (B21 Pro, 80 mm) at 203 dpi. + * Per-print width is `min(engine.headDots, media.printableDots ?? Inf)`, + * with a sensible default when the media descriptor doesn't carry + * `printableDots` (most niimbot media is sized in mm, not dots). + * + * The shared `buildDiagnosticImage` in harness-core sizes the layout + * with a priority pipeline: it drops the lowest-priority sections + * (verbose header line, scale-3 sample, right edge probe) when the + * feed budget can't hold them, so a D11 (96 dots) short-label print + * keeps fewer sections automatically while a B1 (384 dots) at a + * generous budget gets the full layout. + * + * Returns RGBA so the driver's threshold + chunking pipeline runs + * end-to-end. The harness is a fidelity test, not a bypass. + */ +import type { RawImageData } from '@thermal-label/contracts'; +import type { NiimbotDevice, NiimbotMedia } from '@thermal-label/niimbot-core'; +import { buildDiagnosticImage as buildShared } from '@thermal-label/harness-core/shared'; + +export interface DiagnosticPrintInput { + device: NiimbotDevice; + media: NiimbotMedia; + harnessVersion: string; + driverVersion: string; +} + +const DPI_DEFAULT = 203; + +export function buildDiagnosticImage(input: DiagnosticPrintInput): RawImageData { + const engine = input.device.engines[0]; + const dpi = engine?.dpi ?? DPI_DEFAULT; + const headDots = engine?.headDots ?? 384; + // Niimbot media is mm-typed; convert width to dots and clamp to head. + const widthDotsFromMm = Math.round((input.media.widthMm * dpi) / 25.4); + const widthDots = Math.min(headDots, widthDotsFromMm); + // Die-cut media has a real per-label feed budget (`media.heightMm`) + // that harness-core honours as a strict ceiling — dropping + // lower-priority sections so a short 50×30 sticker doesn't print + // across two labels. Continuous niimbot rolls have no per-page + // boundary, so we omit `heightDots` entirely and let harness-core + // fall back to its continuous-stock default budget. + const heightDots = + input.media.type === 'die-cut' && input.media.heightMm !== undefined + ? Math.round((input.media.heightMm * dpi) / 25.4) + : undefined; + return buildShared({ + widthDots, + // exactOptionalPropertyTypes: omit `heightDots` rather than set it + // to undefined when the media is continuous. + ...(heightDots !== undefined ? { heightDots } : {}), + harnessVersion: input.harnessVersion, + driverVersion: input.driverVersion, + driverKey: 'niimbot', + deviceKey: input.device.key, + mediaId: String(input.media.id), + }); +} diff --git a/apps/harness-niimbot/src/main.ts b/apps/harness-niimbot/src/main.ts new file mode 100644 index 0000000..f089e31 --- /dev/null +++ b/apps/harness-niimbot/src/main.ts @@ -0,0 +1,5 @@ +import { createHarness } from '@thermal-label/harness-shell'; +import '@thermal-label/harness-shell/styles'; +import { adapter } from './adapter'; + +createHarness('#app', adapter); diff --git a/apps/harness-niimbot/src/transport/mock.ts b/apps/harness-niimbot/src/transport/mock.ts new file mode 100644 index 0000000..792b27e --- /dev/null +++ b/apps/harness-niimbot/src/transport/mock.ts @@ -0,0 +1,178 @@ +/** + * Mock transport for self-walking the niimbot harness without + * hardware. + * + * Activated by appending `?mock=1` (or `?mock=b1`) to the harness + * URL. The maintainer pre-walks the flow before sending the link to + * a friend or community reporter. + * + * Behaviour: + * - `write(packet)` parses the framed niimbot packet (`55 55 cmd + * len data... crc aa aa`) and queues the matching `In_*` reply + * so the next `read(1)` from the driver's reader loop pulls it + * cleanly. + * - For row-stream opcodes (`PrintBitmapRow`, `PrintEmptyRow`, + * `PrintBitmapRowIndexed`) no reply is queued — those are + * fire-and-forget on the wire. + * - For `PrintStatus` the reply uses a "completed" payload + * (`pages_printed=255, progress=100`) — the b1 strategy's + * completion check (`pages_printed >= copies && progress === 100`) + * fires on the first poll, so the harness mock-print returns + * instantly instead of dragging the operator through a 60-second + * timeout. + * - For Heartbeat the reply mimics `In_HeartbeatAdvanced1` shape so + * `getStatus()` doesn't error during the connect flow. + * - `read()` outside the queued window times out — the driver swallows + * the rejection, matching a real BLE link that misses a notification. + * + * Intentionally tiny — drives the harness UI, doesn't simulate every + * niimbot edge case. Real Web Bluetooth is what the harness exercises + * by default. + */ +import { + TransportClosedError, + TransportTimeoutError, + type Transport, +} from '@thermal-label/contracts'; +import { buildPacket, REPLY } from '@thermal-label/niimbot-core'; + +/** + * Mock target — singleton today (the B1 is the only bench device in + * the maintainer's hands). Defined as a union for symmetry with the + * other harness apps so future targets have a clean extension point. + */ +export type MockTarget = 'b1'; + +interface MockMeta { + key: string; + name: string; +} + +const TARGET_META: Record = { + b1: { key: 'B1', name: 'Niimbot B1' }, +}; + +const CMD = { + PrintStart: 0x01, + PageStart: 0x03, + SetPageSize: 0x13, + SetDensity: 0x21, + SetLabelType: 0x23, + PageEnd: 0xe3, + PrintEnd: 0xf3, + PrintStatus: 0xa3, + PrinterStatusData: 0xa5, + Heartbeat: 0xdc, + RfidInfo2: 0x1c, +} as const; + +export class MockTransport implements Transport { + static currentTarget: MockTarget = 'b1'; + private _connected = true; + private writes = 0; + /** Queued bytes for the next `read()` call(s). */ + private readQueue: Uint8Array = new Uint8Array(0); + + private constructor() { + // Per-target state isn't needed today — B1 is the only target. + } + + static open(target: MockTarget = MockTransport.currentTarget): MockTransport { + MockTransport.currentTarget = target; + return new MockTransport(); + } + + static identityFor(target: MockTarget): MockMeta { + return TARGET_META[target]; + } + + get connected(): boolean { + return this._connected; + } + + async write(data: Uint8Array): Promise { + if (!this._connected) throw new TransportClosedError('bluetooth-gatt'); + this.writes += 1; + // Each write from NiimbotPrinter is a single framed packet. + if (data.length < 7 || data[0] !== 0x55 || data[1] !== 0x55) { + // Unexpected framing — silently drop. Real wire would too. + return; + } + const cmd = data[2] ?? 0; + const reply = this.replyFor(cmd); + if (reply) { + const next = new Uint8Array(this.readQueue.length + reply.length); + next.set(this.readQueue, 0); + next.set(reply, this.readQueue.length); + this.readQueue = next; + } + return Promise.resolve(); + } + + async read(length: number, timeout?: number): Promise { + if (!this._connected) throw new TransportClosedError('bluetooth-gatt'); + if (this.readQueue.length >= length) { + const slice = this.readQueue.subarray(0, length); + this.readQueue = this.readQueue.subarray(length); + return Promise.resolve(new Uint8Array(slice)); + } + // No queued bytes for the requested length — surface a timeout. + return Promise.reject(new TransportTimeoutError('bluetooth-gatt', timeout ?? 0)); + } + + async close(): Promise { + this._connected = false; + return Promise.resolve(); + } + + /** Test / mock-mode introspection — write count since open(). */ + get writeCount(): number { + return this.writes; + } + + // ── reply table ──────────────────────────────────────────────── + + private replyFor(cmd: number): Uint8Array | undefined { + switch (cmd) { + case CMD.Heartbeat: + // 13-byte In_HeartbeatAdvanced1: [reserved×2, cover, paper, rfid, + // batt%, ...]. lid closed, paper present, rfid valid, ~96% batt. + return buildPacket(REPLY.In_HeartbeatAdvanced1, [ + 0x00, 0x00, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, + ]); + case CMD.PrinterStatusData: + return buildPacket(REPLY.In_PrinterStatusData, [ + 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, + ]); + case CMD.RfidInfo2: + // No tag — empty payload. Strategy treats this as "no detected + // media" and falls back to the operator-picked entry. + return buildPacket(REPLY.In_RfidInfo2, [0x00]); + case CMD.SetDensity: + return buildPacket(REPLY.In_SetDensity, [0x01]); + case CMD.SetLabelType: + return buildPacket(REPLY.In_SetLabelType, [0x01]); + case CMD.PrintStart: + return buildPacket(REPLY.In_PrintStart, [0x01]); + case CMD.PageStart: + return buildPacket(REPLY.In_PageStart, [0x01]); + case CMD.SetPageSize: + return buildPacket(REPLY.In_SetPageSize, [0x01, 0x00]); + case CMD.PageEnd: + return buildPacket(REPLY.In_PageEnd, [0x01]); + case CMD.PrintStatus: + // Completion shape: pages_printed=255 (≥ any copies count), + // progress=100. The b1 strategy's check fires on the first + // poll and returns clean. + return buildPacket(REPLY.In_PrintStatus, [ + 0x00, 0xff, 0x64, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + case CMD.PrintEnd: + return buildPacket(REPLY.In_PrintEnd, [0x01]); + default: + // Row-stream opcodes (0x83/0x84/0x85) and anything else — + // no reply on the real wire, no queued reply here. + return undefined; + } + } +} diff --git a/apps/harness-niimbot/src/version.ts b/apps/harness-niimbot/src/version.ts new file mode 100644 index 0000000..796f79a --- /dev/null +++ b/apps/harness-niimbot/src/version.ts @@ -0,0 +1,10 @@ +/** + * Build-time version constants for the harness app. + * + * `HARNESS_VERSION` is this app's package version; `DRIVER_VERSION` + * is the niimbot-core version we built against. CI will inject real + * strings via `vite.config.ts` define so issue bodies report actual + * versions; locally these fall back to "0.0.0-dev". + */ +export const HARNESS_VERSION = '0.0.0-dev'; +export const DRIVER_VERSION = '0.0.0'; diff --git a/apps/harness-niimbot/tsconfig.json b/apps/harness-niimbot/tsconfig.json new file mode 100644 index 0000000..0c2dc00 --- /dev/null +++ b/apps/harness-niimbot/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist-types", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node", "web-bluetooth"], + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true, + "isolatedModules": true, + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true + }, + "include": ["src", "vite.config.ts", "env.d.ts"] +} diff --git a/apps/harness-niimbot/vite.config.ts b/apps/harness-niimbot/vite.config.ts new file mode 100644 index 0000000..0efb5ea --- /dev/null +++ b/apps/harness-niimbot/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { fileURLToPath } from 'node:url'; + +// Sibling-checkout layout: same alias trick as the other harness +// apps. Force every import of `@thermal-label/contracts` to resolve +// to the sibling-checkout's built `dist/index.js` so a freshly-added +// export on the local checkout doesn't get masked by a transitively- +// installed copy in any driver-core's `.pnpm/` store. +const contractsDist = fileURLToPath( + new URL('../../../contracts/dist/index.js', import.meta.url), +); + +export default defineConfig({ + plugins: [vue()], + // Static-bundle output: relative asset paths so the bundle works + // when served from a sub-path (docs site mounts at /harness/niimbot/). + base: './', + resolve: { + alias: { + '@thermal-label/contracts': contractsDist, + }, + }, + optimizeDeps: { + force: false, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2022', + sourcemap: true, + }, +}); diff --git a/eslint.config.js b/eslint.config.js index 66eddea..41427b9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,10 +8,6 @@ export default [ '**/coverage/**', '**/*.d.ts', '**/vitest.config.ts', - // harness-niimbot is cut from the 0.6.0 release (see pnpm-workspace.yaml). - // It is no longer a workspace member, so its deps are never installed and - // type-aware lint cannot resolve them. Exclude it until niimbot ships. - 'apps/harness-niimbot/**', ], }, { diff --git a/package.json b/package.json index 6df9788..d2b553f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@thermal-label/labelwriter-web": "0.6.3-debug.0", "@thermal-label/letratag-core": "^0.6.0", "@thermal-label/letratag-web": "^0.6.0", + "@thermal-label/niimbot-core": "link:../niimbot/packages/core", + "@thermal-label/niimbot-web": "link:../niimbot/packages/web", "@thermal-label/transport": "^0.6.0" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d0ec8b..b179354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,8 @@ overrides: '@thermal-label/labelwriter-web': 0.6.3-debug.0 '@thermal-label/letratag-core': ^0.6.0 '@thermal-label/letratag-web': ^0.6.0 + '@thermal-label/niimbot-core': link:../niimbot/packages/core + '@thermal-label/niimbot-web': link:../niimbot/packages/web '@thermal-label/transport': ^0.6.0 importers: @@ -290,6 +292,64 @@ importers: specifier: ^2.1.0 version: 2.2.12(typescript@5.5.4) + apps/harness-niimbot: + dependencies: + '@mbtech-nl/bitmap': + specifier: ^1.3.0 + version: 1.3.0 + '@thermal-label/contracts': + specifier: ^0.6.0 + version: 0.6.0 + '@thermal-label/harness-components': + specifier: workspace:* + version: link:../../packages/harness-components + '@thermal-label/harness-core': + specifier: workspace:* + version: link:../../packages/harness-core + '@thermal-label/harness-shell': + specifier: workspace:* + version: link:../../packages/harness-shell + '@thermal-label/niimbot-core': + specifier: link:../../../niimbot/packages/core + version: link:../../../niimbot/packages/core + '@thermal-label/niimbot-web': + specifier: link:../../../niimbot/packages/web + version: link:../../../niimbot/packages/web + '@thermal-label/transport': + specifier: ^0.6.0 + version: 0.6.0(usb@2.17.0) + vue: + specifier: ^3.5.0 + version: 3.5.34(typescript@5.5.4) + devDependencies: + '@mbtech-nl/eslint-config': + specifier: ^1.1.0 + version: 1.1.0(@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.5.4))(eslint@9.39.4)(typescript@5.5.4) + '@mbtech-nl/tsconfig': + specifier: ^1.1.0 + version: 1.1.0 + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + '@types/web-bluetooth': + specifier: ^0.0.20 + version: 0.0.20 + '@vitejs/plugin-vue': + specifier: ^5.1.0 + version: 5.2.4(vite@5.4.21(@types/node@22.19.17))(vue@3.5.34(typescript@5.5.4)) + typescript: + specifier: ~5.5.0 + version: 5.5.4 + vite: + specifier: ^5.4.0 + version: 5.4.21(@types/node@22.19.17) + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17)(happy-dom@15.11.7) + vue-tsc: + specifier: ^2.1.0 + version: 2.2.12(typescript@5.5.4) + apps/verify-cli: dependencies: '@inquirer/prompts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 770a1d3..4e708bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,3 @@ packages: - 'packages/*' - 'apps/*' - # harness-niimbot is cut from the 0.6.0 release — niimbot-core/-web are - # unpublished, so it must not be a workspace member or pnpm install fails. - - '!apps/harness-niimbot' From 52225995baa7edd47330494377ee70940117dfef Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 20 May 2026 11:01:39 +0200 Subject: [PATCH 2/5] harness(media-picker): only 'auto-locked' when driver enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker promoted any driver with mediaDetection+match to 'auto-locked' — rendering a yellow banner with 'The printer refuses to print on anything else.' That's only true for brother-ql with media detection on; niimbot's RFID is a hint (the chassis prints whatever you ask), letratag's advertising is hint-only too. Adds opt-in MediaPickerConfig.detectionEnforced flag. brother-ql sets it (canonical enforced case); niimbot + letratag don't. Without it the picker uses 'auto-suggest' — softer 'Detected: X — confirm or pick a different entry' copy, no yellow. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/harness-brother-ql/src/adapter.ts | 5 +++++ .../harness-shell/src/sections/MediaSection.vue | 17 ++++++++++++----- .../src/sections/__tests__/MediaSection.test.ts | 5 +++++ packages/harness-shell/src/types.ts | 13 +++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/harness-brother-ql/src/adapter.ts b/apps/harness-brother-ql/src/adapter.ts index c1a4ff0..41f6037 100644 --- a/apps/harness-brother-ql/src/adapter.ts +++ b/apps/harness-brother-ql/src/adapter.ts @@ -229,6 +229,11 @@ export const adapter: DriverAdapter = { groupBy, swatch, describe: m => m.name, + // Brother QL with media detection enabled is the canonical strict + // case — the printer reads the roll's continuous/die-cut shape and + // refuses to print on anything else. The shell renders the + // "Locked to detected media" banner with the catalogue disabled. + detectionEnforced: true, }, buildDiagnosticImage: ({ device, engine, media, harnessVersion, driverVersion }) => diff --git a/packages/harness-shell/src/sections/MediaSection.vue b/packages/harness-shell/src/sections/MediaSection.vue index 765b89f..5e124bb 100644 --- a/packages/harness-shell/src/sections/MediaSection.vue +++ b/packages/harness-shell/src/sections/MediaSection.vue @@ -140,17 +140,24 @@ const buildCustomMedia = computed<((input: CustomMediaInput) => MediaDescriptor) const detectionCapability = computed(() => { // Drivers expose detection capability per engine via - // `engine.capabilities.mediaDetection`. + // `engine.capabilities.mediaDetection`. `'auto-locked'` is reserved + // for printers that actually enforce the detected media (refuse to + // print on anything else — brother-ql with media-detection on); + // soft-hint detection (niimbot RFID, LW 450 Duo, letratag advertising) + // stays in `'auto-suggest'` so the operator can override without + // the picker yelling "wrong media". + // // - no capability → 'none' - // - capability, no detectedMedia → 'auto-suggest' - // - capability, detected matches catalogue → 'auto-locked' + // - capability, detected matches catalogue, enforced → 'auto-locked' + // - capability, detected matches catalogue, not enforced → 'auto-suggest' // - capability, detected present, no match, // adapter supplies customMedia.build → 'detected-unrecognized' - // - capability, no match, no customMedia hook → 'auto-suggest' + // - capability, no detection / no match → 'auto-suggest' // (graceful degrade — never hard-lock to a non-catalogue object) const engine = activeEngine.value as { capabilities?: { mediaDetection?: boolean } } | null; if (!engine?.capabilities?.mediaDetection) return 'none'; - if (matchedMedia.value) return 'auto-locked'; + const enforced = adapter.mediaPicker.detectionEnforced === true; + if (matchedMedia.value) return enforced ? 'auto-locked' : 'auto-suggest'; if (rawDetected.value) { return buildCustomMedia.value ? 'detected-unrecognized' : 'auto-suggest'; } diff --git a/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts b/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts index 76359ae..b936ec0 100644 --- a/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts +++ b/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts @@ -86,6 +86,11 @@ function makeAdapter(withCustomMedia: boolean): DriverAdapter media, groupBy: () => ({ key: 'all', label: 'All', priority: 'primary' }), + // The capability tests exercise the auto-locked path, so opt + // into strict enforcement (brother-ql's behaviour). Drivers + // that only soft-hint detection (niimbot, letratag) leave this + // unset and the picker uses 'auto-suggest' instead. + detectionEnforced: true, ...(withCustomMedia ? { customMedia: { build: buildCustomMedia } } : {}), }, buildDiagnosticImage: () => { diff --git a/packages/harness-shell/src/types.ts b/packages/harness-shell/src/types.ts index 04ae204..4f3d916 100644 --- a/packages/harness-shell/src/types.ts +++ b/packages/harness-shell/src/types.ts @@ -240,6 +240,19 @@ export interface MediaPickerConfig { /** Returns a full `TMedia` with real geometry + driver-specific fields. */ build: (input: CustomMediaInput) => TMedia; }; + /** + * `true` when the printer enforces the detected media — it will + * refuse to print on anything else (brother-ql with media detection + * on; some LW 550 NFC paths). The shell then renders the + * "Locked to detected media" banner with the catalogue + * visually disabled. + * + * Default `false`: detection is a *hint*; the operator can still + * pick anything from the catalogue and the printer will accept it + * (niimbot, letratag, LW 450 Duo). The shell renders the softer + * "Detected: X — confirm or pick a different entry" banner. + */ + detectionEnforced?: boolean; } // ─── Diagnostic image ──────────────────────────────────────────── From 21f47d1195fcdf6a58a73abb4575247501a50238 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 20 May 2026 11:09:03 +0200 Subject: [PATCH 3/5] harness(media-picker): auto-suggest now claims the default-fallback slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: in auto-suggest mode the picker mounted, no detection yet, so it auto-picked defaultMediaId as the catalogue fallback. When RFID then arrived, the picker bailed because modelValue !== null — treating the auto-picked default as if the operator had chosen it. Net effect: 'Detected: X' banner shown, but the catalogue list stayed on the default, not on the detected media. Treat 'modelValue still equals defaultMediaId' as 'operator hasn't picked yet' and let detected claim it. Explicit operator picks (modelValue.id !== defaultMediaId) are still respected. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/harness-niimbot/src/adapter.ts | 28 ++++++++++++++++++- .../harness-components/src/MediaPicker.vue | 25 +++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/harness-niimbot/src/adapter.ts b/apps/harness-niimbot/src/adapter.ts index f35842d..584c585 100644 --- a/apps/harness-niimbot/src/adapter.ts +++ b/apps/harness-niimbot/src/adapter.ts @@ -210,6 +210,28 @@ export const adapter: DriverAdapter = { rung: primarySession.rung, ...(primarySession.notes.trim() ? { notes: primarySession.notes.trim() } : {}), }; + // Surface niimbot-specific runtime state (RFID, battery, head temp, + // detected media) into `detected.extra`. The last status snapshot + // is the most recent observation the harness took — post-print if + // a print ran, otherwise the pre-print snapshot from connect. + const status = (primarySession.postPrintStatus ?? primarySession.prePrintStatus) as + | (typeof primarySession.postPrintStatus & { + rfid?: { barcode?: string; usedLengthMm?: number; totalLengthMm?: number; rawHex?: string }; + batteryPercent?: number; + headTemperatureC?: number; + rfidValid?: boolean; + detectedMedia?: { id: string | number }; + }) + | null + | undefined; + const niimbotExtras: Record = {}; + if (status?.rfid !== undefined) niimbotExtras.rfid = status.rfid; + if (status?.batteryPercent !== undefined) niimbotExtras.batteryPercent = status.batteryPercent; + if (status?.headTemperatureC !== undefined) niimbotExtras.headTemperatureC = status.headTemperatureC; + if (status?.rfidValid !== undefined) niimbotExtras.rfidValid = status.rfidValid; + if (status?.detectedMedia !== undefined) { + niimbotExtras.detectedMedia = String(status.detectedMedia.id); + } // Surface the GATT service UUID on `detected` so triage always // sees a service identifier — same fallback pattern as the // letratag adapter (plan 11 §IdentitySnapshot.serviceUuid). @@ -218,7 +240,11 @@ export const adapter: DriverAdapter = { ...(identity.serviceUuid === undefined && gatt ? { serviceUuid: gatt.serviceUuid } : {}), - extra: { ...identity.extra, ...(mocked ? { mocked: true } : {}) }, + extra: { + ...identity.extra, + ...niimbotExtras, + ...(mocked ? { mocked: true } : {}), + }, }; const media = primarySession.media; const report: HardwareReport = { diff --git a/packages/harness-components/src/MediaPicker.vue b/packages/harness-components/src/MediaPicker.vue index 078e0c0..d9f0ad3 100644 --- a/packages/harness-components/src/MediaPicker.vue +++ b/packages/harness-components/src/MediaPicker.vue @@ -241,8 +241,13 @@ const secondaryCount = computed(() => * "selected" something — they couldn't really, the catalogue is * disabled). Updates flow live as the polled status changes: * swap a DK roll, picker reflects within one poll cycle. - * - `auto-suggest` / `none`: only auto-select when `modelValue` - * is null. Once the operator picks, leave their choice alone. + * - `auto-suggest`: detected wins over the catalogue fallback (the + * initial `defaultMediaId` auto-pick), but the operator can + * override and we leave that alone. A `modelValue` that is null + * OR still equals `defaultMediaId` is treated as "operator hasn't + * picked yet" — detected is allowed to claim it. + * - `none`: no detection capability; only auto-select when + * `modelValue` is null. * - `detected` non-null: prefer it over `defaultMediaId`. */ function pickInitial(): void { @@ -259,12 +264,20 @@ function pickInitial(): void { } return; } - // none / auto-suggest: don't clobber an existing operator pick. - if (props.modelValue !== null) return; - if (detectionMode.value !== 'none' && props.detected) { - emit('update:modelValue', props.detected); + // auto-suggest: detected wins over the default-fallback auto-pick + // but not over an explicit operator pick. We can't distinguish those + // two perfectly, so the heuristic is "modelValue still equals + // defaultMediaId" ⇒ treat as not-yet-picked. + if (detectionMode.value === 'auto-suggest' && props.detected) { + const stillOnDefault = + props.modelValue === null || props.modelValue.id === props.defaultMediaId; + if (stillOnDefault && props.modelValue?.id !== props.detected.id) { + emit('update:modelValue', props.detected); + } return; } + // none: don't clobber an existing operator pick. + if (props.modelValue !== null) return; const byId = props.available.find(m => m.id === props.defaultMediaId); emit('update:modelValue', byId ?? props.available[0] ?? null); } From 7b932e9f08ac03e07a6dc3f83d260f40363242dc Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Wed, 20 May 2026 11:24:03 +0200 Subject: [PATCH 4/5] harness-niimbot: prefilled GitHub-report link for unknown RFID barcodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the LW 5xx NFC-unknown flow for niimbot. When the chassis reports an RFID payload whose barcode is not in niimbot-core/data/media.json5, the harness now: 1. Drops into 'detected-unrecognized' mode (geometry-edit panel instead of catalogue lock-in). 2. Operator confirms physical dimensions; customMedia.build synthesises a printable NiimbotMedia for the job. 3. Submit section renders a second CTA — a prefilled GitHub issue at the niimbot repo with the full rfid payload + operator dimensions, ready for the maintainer to append to media.json5. Changes: * apps/harness-niimbot/src/adapter.ts - Add mediaPicker.customMedia.build = buildCustomNiimbotMedia (mirrors apps/harness-labelwriter/src/adapter.ts §200's buildCustomLabelMedia). Synthesises a NiimbotMedia with the operator dimensions, an unknown- sentinel id, empty targetModels/barcodes/skus, and labelTypeId: 1 (matches niimbot-core resolveLabelType's fallback). The print path consults widthMm/heightMm/type only; targetModels is the catalogue-filter axis and bypassed for the synthetic media. - Update the in-progress niimbotExtras rfid type cast to the current shape (source / uuid / barcode / serialNumber / allPaper / usedPaper / labelType / capacity / rawHex) — the earlier draft used the pre-rename usedLengthMm / totalLengthMm fields. * packages/harness-shell/src/sections/MediaSection.vue - Extend rawDetectedMedia so it falls back to status.rfid when detectedMedia is undefined and rfid.barcode is set: synthesises a minimal MediaDescriptor (id=rfid-, name=, type derived from rfid.labelType — 3 ⇒ continuous, anything else ⇒ die-cut). widthMm intentionally left undefined; the picker's geometry-edit panel reads it as 'not set' and renders blank inputs for the operator. - Fold the rfid payload into the existing 'Submit it for the library' link body so the geometry-bearing route + the SubmitSection CTA carry identical context. - dimsOf now tolerates a missing widthMm (renders '?' mm) so the rfid-fallback shape (no width) doesn't render 'NaN × NaN mm'. * packages/harness-shell/src/sections/SubmitSection.vue - Add the second CTA next to the main Submit button — fires iff status.detectedMedia === undefined && status.rfid.barcode is set. Renders 'Catalogue this RFID barcode →' linking to a prefilled issue at adapter.targetRepo, body assembled via buildRfidCatalogueIssue. * packages/harness-shell/src/submit/submit.ts - Add RfidBlock type + renderRfidBlock helper (used by both MediaSection.vue's existing detected-unknown link and the new SubmitSection CTA — single pretty-printer, single shape). - Add buildRfidCatalogueIssue: builds title 'media(): catalogue new barcode (×)' and a body that combines operator-confirmed dimensions with the full rfid block, ready to paste into media.json5. * packages/harness-shell/src/sections/__tests__/MediaSection.test.ts - Add three cases for the rfid-fallback path: detection-mode resolution, the issue body carrying every populated rfid field, and the auto-suggest degrade when rfid has no barcode. * packages/harness-shell/src/__tests__/SubmitSection.rfid.test.ts - New test file covering the SubmitSection CTA: render gating (rfid+no-detectedMedia → render; detected → hide; rfid without barcode → hide), the issue URL contents (barcode + operator dimensions in title; full rfid payload + raw hex in body), and the continuous-media title shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/harness-niimbot/src/adapter.ts | 103 ++++++- .../src/__tests__/SubmitSection.rfid.test.ts | 258 ++++++++++++++++++ .../src/sections/MediaSection.vue | 67 ++++- .../src/sections/SubmitSection.vue | 73 +++++ .../sections/__tests__/MediaSection.test.ts | 76 +++++- packages/harness-shell/src/submit/submit.ts | 106 +++++++ 6 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 packages/harness-shell/src/__tests__/SubmitSection.rfid.test.ts diff --git a/apps/harness-niimbot/src/adapter.ts b/apps/harness-niimbot/src/adapter.ts index f35842d..080b6bf 100644 --- a/apps/harness-niimbot/src/adapter.ts +++ b/apps/harness-niimbot/src/adapter.ts @@ -25,7 +25,7 @@ * pre-completed `PrintStatus` so the strategy resolves instantly * during a self-walk. */ -import type { DriverAdapter, MockSpec } from '@thermal-label/harness-shell'; +import type { CustomMediaInput, DriverAdapter, MockSpec } from '@thermal-label/harness-shell'; import type { HardwareReport, IdentitySnapshot, @@ -129,6 +129,54 @@ function swatch(): MediaSwatch | null { return null; } +/** Sentinel `id` marking a media synthesised by the custom-media flow. */ +const CUSTOM_MEDIA_ID_PREFIX = 'unknown-'; + +/** + * Build a printable `NiimbotMedia` from operator-confirmed dimensions, + * for the `detected-unrecognized` flow — a roll whose RFID barcode is + * not yet in `niimbot/packages/core/data/media.json5`. Mirrors the LW + * 5xx `buildCustomLabelMedia` hook (LW NFC SKU not in the registry). + * + * The niimbot strategy code consumes `widthMm` / `heightMm` / `type` + * from the media + falls back to `labelType: 1` (gap) in + * `resolveLabelType` when no override is passed. `targetModels` is + * informational (catalogue filter only — bypassed for the unrecognized + * media, which is emitted directly as `modelValue`), so empty is safe. + * `barcodes` / `skus` stay empty too — this object isn't a catalogue + * entry, just a print-time geometry holder. + * + * `identifier` carries the operator-supplied barcode / SKU when they + * filled it in. We surface it in the synthetic `id` so the report's + * `confirmed.overrides.media` field reads as `unknown-` — + * triage-readable without crossing into "this is in the catalogue". + */ +function buildCustomNiimbotMedia(input: CustomMediaInput): NiimbotMedia { + const { widthMm, heightMm, type, identifier } = input; + const idSuffix = identifier.trim() || `${String(widthMm)}x${String(heightMm ?? 'continuous')}`; + const derivedName = + identifier.trim() || + (type === 'die-cut' && heightMm !== undefined + ? `${String(widthMm)} × ${String(heightMm)} mm die-cut` + : `${String(widthMm)} mm continuous`); + return { + id: `${CUSTOM_MEDIA_ID_PREFIX}${idSuffix}`, + name: derivedName, + widthMm, + type, + category: type === 'continuous' ? 'continuous' : 'multi-purpose', + targetModels: [], + barcodes: [], + skus: identifier.trim() ? [identifier.trim()] : [], + // Default to `1` (gap / WithGaps) — niimbot-core's resolveLabelType + // also falls back to 1 when no override is passed. The operator's + // RFID dump in the issue body lets the maintainer set the right + // value when they add this to media.json5. + labelTypeId: 1, + ...(type === 'die-cut' && heightMm !== undefined ? { heightMm } : {}), + }; +} + // ─── DriverAdapter ─────────────────────────────────────────────── export const adapter: DriverAdapter = { @@ -194,6 +242,15 @@ export const adapter: DriverAdapter = { groupBy, swatch, describe: m => m.name, + // Niimbot detects media via RFID (`0x1A` paper-tag query). When + // the tag's barcode is in `media.json5`'s `barcodes[]`, the driver + // resolves `status.detectedMedia` and the picker auto-fills. When + // it isn't (a new OEM SKU we haven't bench-captured yet), the + // shell synthesises a `rawDetectedMedia` from `status.rfid` so the + // picker drops into `detected-unrecognized` mode — operator types + // the physical dimensions, this builder produces a printable + // media, and SubmitSection renders the catalogue-contribution CTA. + customMedia: { build: buildCustomNiimbotMedia }, }, buildDiagnosticImage: ({ device, media, harnessVersion, driverVersion }) => @@ -210,6 +267,44 @@ export const adapter: DriverAdapter = { rung: primarySession.rung, ...(primarySession.notes.trim() ? { notes: primarySession.notes.trim() } : {}), }; + // Surface niimbot-specific runtime state (RFID, battery, head temp, + // detected media) into `detected.extra`. The last status snapshot + // is the most recent observation the harness took — post-print if + // a print ran, otherwise the pre-print snapshot from connect. + // + // The RFID block shape matches `NiimbotStatus.rfid` (niimbot-core + // types.ts): `source / uuid / barcode / serialNumber / allPaper / + // usedPaper / labelType / capacity / rawHex`. An earlier draft of + // this surfacing used the pre-rename names (`usedLengthMm` / + // `totalLengthMm`); keep this shape in sync with the driver. + const status = (primarySession.postPrintStatus ?? primarySession.prePrintStatus) as + | (typeof primarySession.postPrintStatus & { + rfid?: { + source?: 'paper' | 'ribbon'; + uuid?: string; + barcode?: string; + serialNumber?: string; + allPaper?: number; + usedPaper?: number; + labelType?: number; + capacity?: number; + rawHex?: string; + }; + batteryPercent?: number; + headTemperatureC?: number; + rfidValid?: boolean; + detectedMedia?: { id: string | number }; + }) + | null + | undefined; + const niimbotExtras: Record = {}; + if (status?.rfid !== undefined) niimbotExtras.rfid = status.rfid; + if (status?.batteryPercent !== undefined) niimbotExtras.batteryPercent = status.batteryPercent; + if (status?.headTemperatureC !== undefined) niimbotExtras.headTemperatureC = status.headTemperatureC; + if (status?.rfidValid !== undefined) niimbotExtras.rfidValid = status.rfidValid; + if (status?.detectedMedia !== undefined) { + niimbotExtras.detectedMedia = String(status.detectedMedia.id); + } // Surface the GATT service UUID on `detected` so triage always // sees a service identifier — same fallback pattern as the // letratag adapter (plan 11 §IdentitySnapshot.serviceUuid). @@ -218,7 +313,11 @@ export const adapter: DriverAdapter = { ...(identity.serviceUuid === undefined && gatt ? { serviceUuid: gatt.serviceUuid } : {}), - extra: { ...identity.extra, ...(mocked ? { mocked: true } : {}) }, + extra: { + ...identity.extra, + ...niimbotExtras, + ...(mocked ? { mocked: true } : {}), + }, }; const media = primarySession.media; const report: HardwareReport = { diff --git a/packages/harness-shell/src/__tests__/SubmitSection.rfid.test.ts b/packages/harness-shell/src/__tests__/SubmitSection.rfid.test.ts new file mode 100644 index 0000000..d83c24f --- /dev/null +++ b/packages/harness-shell/src/__tests__/SubmitSection.rfid.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for `SubmitSection.vue`'s unrecognized-RFID catalogue CTA. + * + * Mirrors the LW 5xx unrecognized-NFC catalogue-contribution flow for + * niimbot: when the chassis reported an RFID barcode the driver + * couldn't resolve to a catalogue entry (`status.detectedMedia` + * undefined, `status.rfid.barcode` populated), SubmitSection renders a + * second CTA next to "Submit verification report" — a prefilled GitHub + * issue at the driver repo with the operator-confirmed dimensions + + * the full rfid payload pretty-printed for the new `media.json5` entry. + */ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; +import type { MediaDescriptor, PrintEngine, PrinterStatus } from '@thermal-label/contracts'; +import SubmitSection from '../sections/SubmitSection.vue'; +import { provideAdapter } from '../state/adapterContext'; +import { provideSession, type Session } from '../state/session'; +import type { DriverAdapter } from '../types'; + +interface FakeDevice { + key: string; + name: string; + engines: readonly PrintEngine[]; +} + +const NIIMBOT_ENGINE: PrintEngine = { + role: 'primary', + protocol: 'niimbot-v1', + dpi: 203, + headDots: 384, + capabilities: { mediaDetection: true }, +}; + +const FAKE_DEVICE: FakeDevice = { + key: 'B1', + name: 'Niimbot B1', + engines: [NIIMBOT_ENGINE], +}; + +const fakeAdapter: DriverAdapter = { + driverKey: 'niimbot', + driverDisplayName: 'Niimbot', + targetRepo: 'thermal-label/niimbot', + harnessVersion: '0.0.0-test', + driverVersion: '0.0.0-test', + devices: [FAKE_DEVICE], + media: [], + deviceKey: d => d.key, + deviceName: d => d.name, + // eslint-disable-next-line @typescript-eslint/require-await + connect: async () => { + throw new Error('not used'); + }, + mockTargets: {}, + mediaPicker: { + filterByDeviceEngine: media => media, + groupBy: () => ({ key: 'all', label: 'All', priority: 'primary' }), + }, + buildDiagnosticImage: () => ({ data: new Uint8Array(0), width: 0, height: 0 }), + buildReport: () => ({ + schemaVersion: 1, + driver: 'niimbot', + driverVersion: '0.0.0-test', + harnessVersion: '0.0.0-test', + device: { detected: { advertisedName: 'Niimbot B1' }, confirmed: { model: 'Niimbot B1' } }, + transports: [{ name: 'bluetooth-gatt', patterns: { diagnostic: 'pass' }, rung: 'verified' }], + submittedAt: '2026-05-20T00:00:00.000Z', + }), +}; + +interface RfidShape { + source?: 'paper' | 'ribbon'; + uuid?: string; + barcode?: string; + serialNumber?: string; + allPaper?: number; + usedPaper?: number; + labelType?: number; + capacity?: number; + rawHex?: string; +} + +interface NiimbotStatusShape extends PrinterStatus { + rfid?: RfidShape; +} + +function mountSection(): { + wrapper: ReturnType; + session: Session; +} { + let session!: Session; + const Inner = defineComponent({ + setup() { + session = provideSession(); + return () => h(SubmitSection); + }, + }); + const Wrapper = defineComponent({ + setup() { + provideAdapter(fakeAdapter); + return () => h(Inner); + }, + }); + const wrapper = mount(Wrapper); + return { wrapper, session }; +} + +/** + * Drive the session into a connected, fully-assessed state with the + * given status snapshot on the primary engine. `canSubmit` keys off + * `assessedCount >= 1`, so each connected engine needs a rung set. + */ +function connectAndAssess( + session: Session, + status: NiimbotStatusShape, + media: MediaDescriptor | null, +): void { + session.device.value = FAKE_DEVICE; + session.syncEngineSessions(FAKE_DEVICE); + session.connection.printers = { primary: {} as never }; + session.connection.identity = { advertisedName: 'Niimbot B1' }; + session.connection.mocked = true; + session.printerStatus.primary = status; + const slot = session.engineSessions.primary; + if (slot) { + slot.media = media; + slot.printed = true; + slot.rung = 'verified'; + } +} + +const UNKNOWN_RFID_STATUS: NiimbotStatusShape = { + ready: true, + mediaLoaded: true, + errors: [], + rawBytes: new Uint8Array(0), + rfid: { + source: 'paper', + uuid: '1122334455667788', + barcode: 'NEW-OEM-BARCODE-42', + serialNumber: 'SN9001', + allPaper: 200, + usedPaper: 17, + labelType: 1, + capacity: 100, + rawHex: 'aa11bb22cc33', + }, + // detectedMedia intentionally absent — the unknown-barcode shape. +}; + +const CONFIRMED_CUSTOM_MEDIA: MediaDescriptor = { + id: 'unknown-NEW-OEM-BARCODE-42', + name: 'NEW-OEM-BARCODE-42', + widthMm: 50, + heightMm: 30, + type: 'die-cut', +}; + +/** + * Decode an href the way an operator would read it. `URLSearchParams` + * encodes spaces as `+` and `decodeURIComponent` doesn't reverse that, + * so a manual `+` → ` ` replacement comes after. + */ +function decodeHref(href: string): string { + return decodeURIComponent(href).replaceAll('+', ' '); +} + +describe('SubmitSection — unrecognized RFID catalogue CTA', () => { + it('renders the CTA when status carries an unrecognized rfid barcode', async () => { + const { wrapper, session } = mountSection(); + connectAndAssess(session, UNKNOWN_RFID_STATUS, CONFIRMED_CUSTOM_MEDIA); + await nextTick(); + + const cta = wrapper.find('.catalogue-cta'); + expect(cta.exists()).toBe(true); + expect(cta.text()).toContain('Catalogue this RFID barcode'); + }); + + it('omits the CTA when detectedMedia is set (catalogued roll)', async () => { + const { wrapper, session } = mountSection(); + const status: NiimbotStatusShape = { + ...UNKNOWN_RFID_STATUS, + detectedMedia: { id: 'known', name: 'Known', widthMm: 50, type: 'die-cut' }, + }; + connectAndAssess(session, status, CONFIRMED_CUSTOM_MEDIA); + await nextTick(); + expect(wrapper.find('.catalogue-cta').exists()).toBe(false); + }); + + it('omits the CTA when rfid is absent', async () => { + const { wrapper, session } = mountSection(); + const status: NiimbotStatusShape = { + ready: true, + mediaLoaded: true, + errors: [], + rawBytes: new Uint8Array(0), + }; + connectAndAssess(session, status, CONFIRMED_CUSTOM_MEDIA); + await nextTick(); + expect(wrapper.find('.catalogue-cta').exists()).toBe(false); + }); + + it('omits the CTA when rfid has no barcode field', async () => { + const { wrapper, session } = mountSection(); + const status: NiimbotStatusShape = { + ready: true, + mediaLoaded: true, + errors: [], + rawBytes: new Uint8Array(0), + rfid: { source: 'paper', uuid: 'deadbeef' }, + }; + connectAndAssess(session, status, CONFIRMED_CUSTOM_MEDIA); + await nextTick(); + expect(wrapper.find('.catalogue-cta').exists()).toBe(false); + }); + + it("prefills the issue URL with the unknown barcode, operator dimensions, and rfid payload", async () => { + const { wrapper, session } = mountSection(); + connectAndAssess(session, UNKNOWN_RFID_STATUS, CONFIRMED_CUSTOM_MEDIA); + await nextTick(); + + const cta = wrapper.find('.catalogue-cta'); + const href = decodeHref(cta.attributes('href') ?? ''); + expect(href).toContain('thermal-label/niimbot/issues/new'); + // Title carries the barcode + the operator-confirmed dimensions. + expect(href).toContain('media(niimbot): catalogue new barcode NEW-OEM-BARCODE-42 (50×30)'); + // Body carries the operator-confirmed dimensions. + expect(href).toContain('Operator-confirmed dimensions'); + expect(href).toContain('50 × 30 mm'); + // Body carries the full RFID payload. + expect(href).toContain('uuid: 1122334455667788'); + expect(href).toContain('barcode: NEW-OEM-BARCODE-42'); + expect(href).toContain('serialNumber: SN9001'); + expect(href).toContain('allPaper: 200'); + expect(href).toContain('usedPaper: 17'); + expect(href).toContain('labelType: 1'); + expect(href).toContain('capacity: 100'); + // Raw hex dump rides through as a fenced block. + expect(href).toContain('aa11bb22cc33'); + }); + + it("renders the continuous-dimension shape in the title when media.type is continuous", async () => { + const { wrapper, session } = mountSection(); + const continuousMedia: MediaDescriptor = { + id: 'unknown-NEW-OEM-BARCODE-42', + name: 'NEW-OEM-BARCODE-42', + widthMm: 50, + type: 'continuous', + }; + connectAndAssess(session, UNKNOWN_RFID_STATUS, continuousMedia); + await nextTick(); + + const href = decodeHref(wrapper.find('.catalogue-cta').attributes('href') ?? ''); + expect(href).toContain('media(niimbot): catalogue new barcode NEW-OEM-BARCODE-42 (50×continuous)'); + expect(href).toContain('Width: 50 mm (continuous)'); + }); +}); diff --git a/packages/harness-shell/src/sections/MediaSection.vue b/packages/harness-shell/src/sections/MediaSection.vue index 5e124bb..609a59f 100644 --- a/packages/harness-shell/src/sections/MediaSection.vue +++ b/packages/harness-shell/src/sections/MediaSection.vue @@ -27,6 +27,7 @@ import type { DetectionCapability } from '@thermal-label/harness-components/type import { useAdapter } from '../state/adapterContext'; import { useSession } from '../state/session'; import { engineNoun, statusToMediaPill } from '../state/statusPills'; +import { renderRfidBlock, type RfidBlock } from '../submit/submit'; import type { CustomMediaInput } from '../types'; import SectionCard from './SectionCard.vue'; @@ -60,9 +61,43 @@ const compatibleMedia = computed(() => { * The raw `detectedMedia` off the polled `PrinterStatus`, or `null` * when the driver reports nothing. Drivers that can't detect (LM, * LW 450) leave `detectedMedia` undefined. + * + * Niimbot fallback: the niimbot driver only populates `detectedMedia` + * when the RFID tag's barcode is in `media.json5`'s `barcodes[]`. For + * any new OEM SKU the chassis reports `status.rfid.barcode` but leaves + * `detectedMedia` undefined. Synthesise a minimal `MediaDescriptor` + * from the rfid block so the `detected-unrecognized` panel engages and + * the catalogue-contribution invite can carry the barcode. Geometry is + * not on the rfid tag (only roll-length / labelType codes), so + * `widthMm` / `heightMm` stay undefined — the picker's panel renders + * blank "Width (mm)" / "Length (mm)" inputs and the operator types in + * the physical dimensions. */ const rawDetectedMedia = computed(() => { - return (session.activeStatus.value?.detectedMedia as MediaDescriptor | undefined) ?? null; + const status = session.activeStatus.value as + | (typeof session.activeStatus.value & { rfid?: RfidBlock }) + | null; + const direct = (status?.detectedMedia as MediaDescriptor | undefined) ?? null; + if (direct) return direct; + // No catalogue match — fall back to the rfid beacon if present. + const barcode = status?.rfid?.barcode?.trim(); + if (!barcode) return null; + // labelType 3 (Continuous) is the only continuous code in the niimbot + // enum today; everything else (1 WithGaps, 2 Black, 4 Perforated, 5 + // Transparent, 6 PvcTag, 10 BlackMarkGap, 11 HeatShrinkTube) is some + // flavour of die-cut / segmented. Default to continuous when the code + // is missing — die-cut requires a length the operator must supply. + const type: 'die-cut' | 'continuous' = + status?.rfid?.labelType === 3 || status?.rfid?.labelType === undefined ? 'continuous' : 'die-cut'; + // widthMm intentionally left undefined: niimbot RFID doesn't carry + // physical dimensions. Cast through `unknown` to satisfy the + // required-by-type-but-undefined-at-runtime shape; the picker's panel + // and the issue-link builder both probe with `typeof`. + return { + id: `rfid-${barcode}`, + name: barcode, + type, + } as unknown as MediaDescriptor; }); /** @@ -230,11 +265,17 @@ const detectedSkuRawBytes = computed(() => { return typeof raw === 'string' && raw.trim() ? raw.trim() : null; }); -/** `" × mm"` for a fixed length, `" mm continuous"` otherwise. */ +/** + * `" × mm"` for a fixed length, `" mm continuous"` otherwise. + * `widthMm` is required on `MediaDescriptor` but the niimbot rfid + * fallback (geometry not on the tag) leaves it undefined at runtime; + * `'?'` is the user-visible placeholder until the operator types one. + */ function dimsOf(m: MediaDescriptor): string { + const w = typeof m.widthMm === 'number' && m.widthMm > 0 ? String(m.widthMm) : '?'; return typeof m.heightMm === 'number' && m.heightMm > 0 - ? `${String(m.widthMm)} × ${String(m.heightMm)} mm` - : `${String(m.widthMm)} mm continuous`; + ? `${w} × ${String(m.heightMm)} mm` + : `${w} mm continuous`; } /** @@ -248,6 +289,22 @@ function rawBytesBlock(): string { return raw ? `\nRaw SKU dump (hex):\n\`\`\`\n${raw}\n\`\`\`\n` : ''; } +/** + * Niimbot-RFID block — present when `status.rfid` carries a tag + * payload. The catalogue-contribution issue body pretty-prints this so + * the maintainer can paste it verbatim into a new `media.json5` entry + * (barcode + labelType become catalogue fields; allPaper / usedPaper + * are roll-instance forensics). + */ +const rfidBlock = computed(() => { + const status = session.activeStatus.value as + | (typeof session.activeStatus.value & { rfid?: RfidBlock }) + | null; + const rfid = status?.rfid; + if (!rfid || !rfid.barcode) return null; + return rfid; +}); + /** * Prefilled "add this media to the catalogue" issue. One builder, one * destination (`/issues/new`), one title shape — both the @@ -272,6 +329,7 @@ const issueUrl = computed(() => { if (detected) { subject = detected.name; + const rfid = rfidBlock.value; body = `The harness detected a roll that isn't in the thermal-label media catalogue yet.\n\n` + `Driver: ${driver}\n` + @@ -281,6 +339,7 @@ const issueUrl = computed(() => { `- Dimensions: ${dimsOf(detected)}\n` + `- Type: ${detected.type}\n` + rawBytesBlock() + + (rfid ? renderRfidBlock(rfid) : '') + `\nPlease add this SKU to the driver's media registry.\n`; } else { subject = 'a label type'; diff --git a/packages/harness-shell/src/sections/SubmitSection.vue b/packages/harness-shell/src/sections/SubmitSection.vue index c4ee4a0..3d489a7 100644 --- a/packages/harness-shell/src/sections/SubmitSection.vue +++ b/packages/harness-shell/src/sections/SubmitSection.vue @@ -28,10 +28,12 @@ import { useSession } from '../state/session'; import { buildIssueTitle, buildPrefillUrl, + buildRfidCatalogueIssue, copyToClipboard, renderBody, submitReport, urlExceedsLimit, + type RfidBlock, type SubmitResult, } from '../submit/submit'; import SectionCard from './SectionCard.vue'; @@ -282,6 +284,53 @@ const coverageRows = computed(() => { function activate(role: string): void { session.selectedRole.value = role; } + +// ─── Niimbot: catalogue-new-RFID-barcode CTA ───────────────────── +// +// Mirrors the LW 5xx unrecognized-NFC SubmitSection flow for niimbot. +// Fires when the chassis reported an RFID barcode whose value isn't +// in the driver's media catalogue — `status.detectedMedia` is +// undefined but `status.rfid.barcode` is populated. The MediaSection +// has by then dropped into `detected-unrecognized` mode and the +// operator has confirmed physical dimensions (the picker synthesises +// a media via `adapter.mediaPicker.customMedia.build`); this CTA +// renders the operator-confirmed dimensions + the full rfid payload +// as a prefilled GitHub issue body the maintainer can paste straight +// into `media.json5`. + +const rfidUnknownIssue = computed<{ url: string; title: string } | null>(() => { + const status = session.activeStatus.value as + | (typeof session.activeStatus.value & { + detectedMedia?: unknown; + rfid?: RfidBlock; + }) + | null; + if (!status) return null; + // Only fire on the unknown-barcode shape: rfid carries a barcode AND + // the driver couldn't resolve it to a catalogue entry. + if (status.detectedMedia !== undefined) return null; + const rfid = status.rfid; + if (!rfid?.barcode) return null; + + // Operator-confirmed dimensions ride from the synthetic media the + // picker emitted. Undefined when the operator hasn't reached the + // panel yet — the issue body still files, just with the unconfirmed + // placeholders. + const media = session.activeSession.value?.media as + | { widthMm?: number; heightMm?: number; type?: string; name?: string } + | null + | undefined; + + const device = session.device.value ? adapter.deviceName(session.device.value) : '(unknown)'; + const { url, title } = buildRfidCatalogueIssue({ + driver: adapter.driverKey, + targetRepo: adapter.targetRepo, + device, + rfid, + confirmedMedia: media ?? null, + }); + return { url, title }; +}); @@ -493,6 +559,13 @@ function activate(role: string): void { background: var(--bg); } +.catalogue-cta { + display: inline-flex; + align-items: center; + text-decoration: none; + cursor: pointer; +} + .ok-banner { background: var(--ok-bg); color: var(--ok); diff --git a/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts b/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts index b936ec0..598ba06 100644 --- a/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts +++ b/packages/harness-shell/src/sections/__tests__/MediaSection.test.ts @@ -112,6 +112,23 @@ async function mountWithDetection(opts: { detectedMedia: MediaDescriptor | undefined; /** Hex SKU dump to seed onto the active engine session's `skuInfo`. */ skuRawBytes?: string; + /** + * Niimbot-style rfid block to seed onto the active status. When + * `detectedMedia` is undefined and `rfid.barcode` is present, the + * MediaSection synthesises a `rawDetected` from rfid so the picker + * drops into `detected-unrecognized` mode. + */ + rfid?: { + source?: 'paper' | 'ribbon'; + uuid?: string; + barcode?: string; + serialNumber?: string; + allPaper?: number; + usedPaper?: number; + labelType?: number; + capacity?: number; + rawHex?: string; + }; }): Promise> { const adapter = makeAdapter(opts.withCustomMedia); @@ -131,12 +148,13 @@ async function mountWithDetection(opts: { const slot = session.engineSessions.primary; if (slot) slot.skuInfo = { rawBytes: opts.skuRawBytes }; } - const status: PrinterStatus = { + const status: PrinterStatus & { rfid?: typeof opts.rfid } = { ready: true, mediaLoaded: true, errors: [], rawBytes: new Uint8Array(0), ...(opts.detectedMedia ? { detectedMedia: opts.detectedMedia } : {}), + ...(opts.rfid ? { rfid: opts.rfid } : {}), }; session.printerStatus.primary = status; return () => h(MediaSection); @@ -271,6 +289,62 @@ describe('MediaSection — detectionCapability', () => { expect(wrapper.find('.detected-unknown').exists()).toBe(false); }); + it('resolves detected-unrecognized from rfid.barcode when detectedMedia is undefined', async () => { + // Niimbot scenario: chassis read an RFID barcode the driver + // couldn't resolve to a catalogue entry. The shell synthesises a + // rawDetected from rfid so the picker engages the geometry-edit + // panel. + const mode = detectionModeOf( + await mountWithDetection({ + withCustomMedia: true, + detectedMedia: undefined, + rfid: { source: 'paper', uuid: 'deadbeef', barcode: 'NEW-OEM-XYZ' }, + }), + ); + expect(mode).toBe('detected-unrecognized'); + }); + + it('folds the full rfid payload into the prefilled catalogue invite', async () => { + const wrapper = await mountWithDetection({ + withCustomMedia: true, + detectedMedia: undefined, + rfid: { + source: 'paper', + uuid: '1122334455667788', + barcode: 'NEW-OEM-XYZ', + serialNumber: 'SN42', + allPaper: 200, + usedPaper: 7, + labelType: 1, + rawHex: 'aa11bb22', + }, + }); + const href = decodeURIComponent(wrapper.find('.detected-unknown a').attributes('href') ?? ''); + // The synthesised barcode flows into the subject. + expect(href).toContain('NEW-OEM-XYZ'); + // The pretty-printed rfid block carries every populated field. + expect(href).toContain('uuid: 1122334455667788'); + expect(href).toContain('barcode: NEW-OEM-XYZ'); + expect(href).toContain('serialNumber: SN42'); + expect(href).toContain('allPaper: 200'); + expect(href).toContain('usedPaper: 7'); + expect(href).toContain('labelType: 1'); + expect(href).toContain('aa11bb22'); + }); + + it('omits the rfid fallback when the barcode is empty', async () => { + // rfid present but no barcode → no synthesised detection. Picker + // degrades to auto-suggest just like a hint-less detection. + const mode = detectionModeOf( + await mountWithDetection({ + withCustomMedia: true, + detectedMedia: undefined, + rfid: { source: 'paper', uuid: 'deadbeef' }, + }), + ); + expect(mode).toBe('auto-suggest'); + }); + it('folds detection context into the generic add-support link when a roll matched the catalogue', async () => { // auto-locked: a catalogued roll is loaded, so the generic link // shows — and still carries what the printer reported plus the diff --git a/packages/harness-shell/src/submit/submit.ts b/packages/harness-shell/src/submit/submit.ts index 81f7587..afdd11e 100644 --- a/packages/harness-shell/src/submit/submit.ts +++ b/packages/harness-shell/src/submit/submit.ts @@ -11,6 +11,112 @@ */ import { renderIssueBody, type HardwareReport } from '@thermal-label/harness-core/shared'; +/** + * Niimbot-RFID block as it lands on `NiimbotStatus.rfid`. Defined here + * (rather than imported from niimbot-core) so the shell stays + * driver-agnostic at the type layer — the shape is duck-typed off + * whatever the driver puts on `status.rfid`. + */ +export interface RfidBlock { + source?: 'paper' | 'ribbon'; + uuid?: string; + barcode?: string; + serialNumber?: string; + allPaper?: number; + usedPaper?: number; + labelType?: number; + capacity?: number; + rawHex?: string; +} + +/** + * Pretty-print an RFID payload for the catalogue-contribution issue + * body. Lists only the fields the niimbot parser populated; ends with a + * fenced hex dump when the raw bytes are available. The maintainer + * pastes this verbatim into a new `media.json5` entry — barcode + + * labelType become catalogue fields; allPaper / usedPaper are + * roll-instance forensics that survive in the report. + */ +export function renderRfidBlock(rfid: RfidBlock): string { + const lines: string[] = ['', 'RFID payload (from `0x1A` / `0x1C`):']; + if (rfid.source !== undefined) lines.push(`- source: ${rfid.source}`); + if (rfid.uuid !== undefined) lines.push(`- uuid: ${rfid.uuid}`); + if (rfid.barcode !== undefined) lines.push(`- barcode: ${rfid.barcode}`); + if (rfid.serialNumber !== undefined) lines.push(`- serialNumber: ${rfid.serialNumber}`); + if (rfid.allPaper !== undefined) lines.push(`- allPaper: ${String(rfid.allPaper)}`); + if (rfid.usedPaper !== undefined) lines.push(`- usedPaper: ${String(rfid.usedPaper)}`); + if (rfid.labelType !== undefined) lines.push(`- labelType: ${String(rfid.labelType)}`); + if (rfid.capacity !== undefined) lines.push(`- capacity: ${String(rfid.capacity)}`); + if (rfid.rawHex !== undefined) { + lines.push('', 'Raw RFID payload (hex):', '```', rfid.rawHex, '```'); + } + return `${lines.join('\n')}\n`; +} + +/** + * Prefilled "catalogue this new RFID barcode" GitHub issue, mirroring + * the LW 5xx unrecognized-NFC flow for niimbot. Fires once the operator + * has confirmed physical dimensions in the `detected-unrecognized` + * panel (the synthetic media id starts with `unknown-`), so the body + * carries everything the maintainer needs to append a `media.json5` + * entry without a second round-trip. + */ +export function buildRfidCatalogueIssue(opts: { + driver: string; + targetRepo: string; + device: string; + rfid: RfidBlock; + /** Operator-confirmed media (from `customMedia.build`). */ + confirmedMedia?: { + widthMm?: number; + heightMm?: number; + type?: string; + name?: string; + } | null; +}): { url: string; title: string; body: string } { + const { driver, targetRepo, device, rfid, confirmedMedia } = opts; + const w = + typeof confirmedMedia?.widthMm === 'number' && confirmedMedia.widthMm > 0 + ? String(confirmedMedia.widthMm) + : '?'; + const h = + typeof confirmedMedia?.heightMm === 'number' && confirmedMedia.heightMm > 0 + ? String(confirmedMedia.heightMm) + : confirmedMedia?.type === 'continuous' + ? 'continuous' + : '?'; + const barcode = rfid.barcode ?? '(unknown)'; + const title = `media(${driver}): catalogue new barcode ${barcode} (${w}×${h})`; + const dimsLine = + confirmedMedia?.type === 'continuous' + ? `- Width: ${w} mm (continuous)` + : `- Dimensions: ${w} × ${h} mm`; + const identifierLine = confirmedMedia?.name + ? `- Operator identifier: ${confirmedMedia.name}\n` + : ''; + const intro = + `The harness read an RFID tag whose barcode isn't in ` + + `\`niimbot/packages/core/data/media.json5\` yet. Operator-confirmed ` + + `physical dimensions below; full RFID payload pretty-printed for the ` + + `new catalogue entry.`; + const footer = + `Please append a new entry to \`media.json5\` using the barcode + ` + + `labelType above and the operator-confirmed dimensions.`; + const body = `${intro} + +Driver: ${driver} +Device: ${device} + +Operator-confirmed dimensions: +${dimsLine} +- Type: ${confirmedMedia?.type ?? '(not confirmed)'} +${identifierLine}${renderRfidBlock(rfid)} +${footer} +`; + const url = buildPrefillUrl(targetRepo, title, body); + return { url, title, body }; +} + /** * GitHub's prefill URL has a soft limit somewhere around 8 kB. We * stay conservative and switch to the clipboard-fallback path before From 99027c5bf767f0c18a92b23146516286c641b53c Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Thu, 21 May 2026 01:46:39 +0200 Subject: [PATCH 5/5] package: link transport for the niimbot autodetect probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `niimbot-web@link` is calling `WebBluetoothTransport.requestAny` and `fromDevice` — both added to the transport package today but not in published 0.6.0. Swap the override from `^0.6.0` to `link:../transport` so the bundle picks up the new methods. Mirrors the existing labelwriter-core/web prerelease pin pattern. Must swap back to a published version (≥0.6.1 once that ships) before any harness release build, alongside the labelwriter pin removal — see [[project_harness_labelwriter_link_override]]. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 78 +++++++++++++++++--------------------------------- 2 files changed, 27 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index d2b553f..64de8ff 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@thermal-label/letratag-web": "^0.6.0", "@thermal-label/niimbot-core": "link:../niimbot/packages/core", "@thermal-label/niimbot-web": "link:../niimbot/packages/web", - "@thermal-label/transport": "^0.6.0" + "@thermal-label/transport": "link:../transport" } }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b179354..fe7256d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ overrides: '@thermal-label/letratag-web': ^0.6.0 '@thermal-label/niimbot-core': link:../niimbot/packages/core '@thermal-label/niimbot-web': link:../niimbot/packages/web - '@thermal-label/transport': ^0.6.0 + '@thermal-label/transport': link:../transport importers: @@ -58,7 +58,7 @@ importers: version: 0.6.0 '@thermal-label/brother-ql-web': specifier: ^0.6.0 - version: 0.6.0(typescript@5.5.4)(usb@2.17.0) + version: 0.6.0(typescript@5.5.4) '@thermal-label/contracts': specifier: ^0.6.0 version: 0.6.0 @@ -72,8 +72,8 @@ importers: specifier: workspace:* version: link:../../packages/harness-shell '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -134,10 +134,10 @@ importers: version: 0.6.0 '@thermal-label/labelmanager-web': specifier: ^0.6.0 - version: 0.6.0(typescript@5.5.4)(usb@2.17.0) + version: 0.6.0(typescript@5.5.4) '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -198,10 +198,10 @@ importers: version: 0.6.3-debug.0(@thermal-label/d1-core@0.6.0) '@thermal-label/labelwriter-web': specifier: 0.6.3-debug.0 - version: 0.6.3-debug.0(@thermal-label/d1-core@0.6.0)(typescript@5.5.4)(usb@2.17.0) + version: 0.6.3-debug.0(@thermal-label/d1-core@0.6.0)(typescript@5.5.4) '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -256,10 +256,10 @@ importers: version: 0.6.0 '@thermal-label/letratag-web': specifier: ^0.6.0 - version: 0.6.0(@thermal-label/contracts@0.6.0)(@thermal-label/letratag-core@0.6.0)(@thermal-label/transport@0.6.0(usb@2.17.0)) + version: 0.6.0(@thermal-label/contracts@0.6.0)(@thermal-label/letratag-core@0.6.0)(@thermal-label/transport@transport) '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -316,8 +316,8 @@ importers: specifier: link:../../../niimbot/packages/web version: link:../../../niimbot/packages/web '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -374,8 +374,8 @@ importers: specifier: 0.6.3-debug.0 version: 0.6.3-debug.0(@thermal-label/d1-core@0.6.0) '@thermal-label/transport': - specifier: ^0.6.0 - version: 0.6.0(usb@2.17.0) + specifier: link:../../../transport + version: link:../../../transport commander: specifier: ^12.0.0 version: 12.1.0 @@ -1343,19 +1343,7 @@ packages: peerDependencies: '@thermal-label/contracts': ^0.6.0 '@thermal-label/letratag-core': ^0.6.0 - '@thermal-label/transport': ^0.6.0 - - '@thermal-label/transport@0.6.0': - resolution: {integrity: sha512-Na1e0CrNBhCEFH1mxBTD6WUmuyjwuysZIUnBeYTPk2Y8FpAVSxKbXPs8INFBTQ4sJbRehkT8d/TgGjFhrZ6PFQ==} - engines: {node: '>=20.9.0'} - peerDependencies: - serialport: '>=12.0.0' - usb: '>=2.14.0' - peerDependenciesMeta: - serialport: - optional: true - usb: - optional: true + '@thermal-label/transport': link:/home/mannes/thermal-label/transport '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -3534,15 +3522,12 @@ snapshots: '@mbtech-nl/bitmap': 1.3.0 '@thermal-label/contracts': 0.6.0 - '@thermal-label/brother-ql-web@0.6.0(typescript@5.5.4)(usb@2.17.0)': + '@thermal-label/brother-ql-web@0.6.0(typescript@5.5.4)': dependencies: '@thermal-label/brother-ql-core': 0.6.0 '@thermal-label/contracts': 0.6.0 - '@thermal-label/transport': 0.6.0(usb@2.17.0) + '@thermal-label/transport': link:../transport typescript: 5.5.4 - transitivePeerDependencies: - - serialport - - usb '@thermal-label/contracts@0.6.0': dependencies: @@ -3559,15 +3544,12 @@ snapshots: '@thermal-label/contracts': 0.6.0 '@thermal-label/d1-core': 0.6.0 - '@thermal-label/labelmanager-web@0.6.0(typescript@5.5.4)(usb@2.17.0)': + '@thermal-label/labelmanager-web@0.6.0(typescript@5.5.4)': dependencies: '@thermal-label/contracts': 0.6.0 '@thermal-label/labelmanager-core': 0.6.0 - '@thermal-label/transport': 0.6.0(usb@2.17.0) + '@thermal-label/transport': link:../transport typescript: 5.5.4 - transitivePeerDependencies: - - serialport - - usb '@thermal-label/labelwriter-core@0.6.3-debug.0(@thermal-label/d1-core@0.6.0)': dependencies: @@ -3576,33 +3558,25 @@ snapshots: optionalDependencies: '@thermal-label/d1-core': 0.6.0 - '@thermal-label/labelwriter-web@0.6.3-debug.0(@thermal-label/d1-core@0.6.0)(typescript@5.5.4)(usb@2.17.0)': + '@thermal-label/labelwriter-web@0.6.3-debug.0(@thermal-label/d1-core@0.6.0)(typescript@5.5.4)': dependencies: '@thermal-label/contracts': 0.6.0 '@thermal-label/labelwriter-core': 0.6.3-debug.0(@thermal-label/d1-core@0.6.0) - '@thermal-label/transport': 0.6.0(usb@2.17.0) + '@thermal-label/transport': link:../transport typescript: 5.5.4 transitivePeerDependencies: - '@thermal-label/d1-core' - - serialport - - usb '@thermal-label/letratag-core@0.6.0': dependencies: '@mbtech-nl/bitmap': 1.3.0 '@thermal-label/contracts': 0.6.0 - '@thermal-label/letratag-web@0.6.0(@thermal-label/contracts@0.6.0)(@thermal-label/letratag-core@0.6.0)(@thermal-label/transport@0.6.0(usb@2.17.0))': + '@thermal-label/letratag-web@0.6.0(@thermal-label/contracts@0.6.0)(@thermal-label/letratag-core@0.6.0)(@thermal-label/transport@transport)': dependencies: '@thermal-label/contracts': 0.6.0 '@thermal-label/letratag-core': 0.6.0 - '@thermal-label/transport': 0.6.0(usb@2.17.0) - - '@thermal-label/transport@0.6.0(usb@2.17.0)': - dependencies: - '@thermal-label/contracts': 0.6.0 - optionalDependencies: - usb: 2.17.0 + '@thermal-label/transport': link:../transport '@tybys/wasm-util@0.10.2': dependencies: