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/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..3acbf52 --- /dev/null +++ b/apps/harness-niimbot/src/adapter.ts @@ -0,0 +1,343 @@ +/** + * 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 { CustomMediaInput, 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; +} + +/** 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 = { + 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, + // 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 }) => + 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 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`. + 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). + const detected: IdentitySnapshot = { + ...identity, + ...(identity.serviceUuid === undefined && gatt + ? { serviceUuid: gatt.serviceUuid } + : {}), + extra: { + ...identity.extra, + ...niimbotExtras, + ...(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..64de8ff 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "@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/transport": "^0.6.0" + "@thermal-label/niimbot-core": "link:../niimbot/packages/core", + "@thermal-label/niimbot-web": "link:../niimbot/packages/web", + "@thermal-label/transport": "link:../transport" } }, "devDependencies": { 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); } 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 765b89f..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; }); /** @@ -140,17 +175,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'; } @@ -223,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`; } /** @@ -241,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 @@ -265,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` + @@ -274,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 76359ae..598ba06 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: () => { @@ -107,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); @@ -126,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); @@ -266,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 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 ──────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d0ec8b..fe7256d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,9 @@ 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/transport': ^0.6.0 + '@thermal-label/niimbot-core': link:../niimbot/packages/core + '@thermal-label/niimbot-web': link:../niimbot/packages/web + '@thermal-label/transport': link:../transport importers: @@ -56,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 @@ -70,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) @@ -132,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) @@ -196,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) @@ -254,10 +256,68 @@ 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: link:../../../transport + version: link:../../../transport + 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/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(usb@2.17.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: link:../../../transport + version: link:../../../transport vue: specifier: ^3.5.0 version: 3.5.34(typescript@5.5.4) @@ -314,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 @@ -1283,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==} @@ -3474,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: @@ -3499,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: @@ -3516,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: 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'