From 276735dd85b71ea7c4a85d62ba963d8b5b5d12af Mon Sep 17 00:00:00 2001 From: Siarhei Zotau <8250552+zotovby@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:15:54 +0200 Subject: [PATCH] Add P1 Mini label printer support (TSPL) The P1 Mini (Topprint app) uses the same BLE transport as Marklife printers but prints TSPL jobs. Adds a TSPL protocol, a P1 device profile, and small core/web/CLI changes for printers that don't confirm finished jobs, have no battery query, and print labels unrotated. --- README.md | 2 + docs/adding-a-printer.md | 6 + packages/cli/src/cli/commands/print.ts | 10 +- packages/cli/src/types/index.ts | 2 +- packages/core/src/device/profiles/p1.ts | 36 +++++ packages/core/src/device/registry.ts | 2 + packages/core/src/device/types.ts | 6 + packages/core/src/errors.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/printer.ts | 13 +- packages/core/src/protocol/registry.ts | 2 + packages/core/src/protocol/tspl/commands.ts | 76 ++++++++++ packages/core/src/protocol/tspl/protocol.ts | 133 ++++++++++++++++++ packages/core/src/protocol/types.ts | 11 +- packages/core/test/protocol/tspl.test.ts | 83 +++++++++++ packages/web/src/editor/editor.tsx | 10 +- .../src/editor/top-chrome/printer-chip.tsx | 2 +- packages/web/src/store/printer-store.ts | 2 + 18 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/device/profiles/p1.ts create mode 100644 packages/core/src/protocol/tspl/commands.ts create mode 100644 packages/core/src/protocol/tspl/protocol.ts create mode 100644 packages/core/test/protocol/tspl.test.ts diff --git a/README.md b/README.md index 0b5d85c..44e831f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Marklife P15 Marklife P12 Marklife P7 + P1 Mini L11 Compatible

@@ -145,6 +146,7 @@ thermoprint status | **Marklife P15** | L11 | 384 px (48 mm) | ✅ Fully supported | | **Marklife P12** | L11 | 384 px (48 mm) | ✅ Fully supported | | **Marklife P7** | L11 | 384 px (48 mm) | ✅ Fully supported | +| **P1 Mini** (Topprint app) | TSPL | 384 px (48 mm) | ✅ Supported | | Other L11-compatible | L11 | Varies | Should work | **Have a different printer?** Any label printer using the L11 protocol should work. Adding a new printer is just a [device profile](docs/adding-a-printer.md) — a plain object with BLE service/characteristic UUIDs and settings. diff --git a/docs/adding-a-printer.md b/docs/adding-a-printer.md index 9706ec5..60dc4c3 100644 --- a/docs/adding-a-printer.md +++ b/docs/adding-a-printer.md @@ -148,6 +148,12 @@ export class MyProtocol implements PrinterProtocol { } ``` +Printers that differ from the L11 flow can opt out of parts of it — see `src/protocol/tspl/protocol.ts` for an example: + +- `readonly confirmsPrint = false` — the printer never reports a finished job, so `print()` resolves once all data is sent. +- `buildBatteryQuery()` / `buildInfoQuery()` may return `null` when the printer can't report that value. +- `labelConfig.rotate: false` in the device profile — the label width spans the print head, so designs are printed as-is instead of rotated 90°. + ### 2. Register the protocol Add it to `src/protocol/registry.ts`: diff --git a/packages/cli/src/cli/commands/print.ts b/packages/cli/src/cli/commands/print.ts index 69984cc..c4c9a5b 100644 --- a/packages/cli/src/cli/commands/print.ts +++ b/packages/cli/src/cli/commands/print.ts @@ -8,6 +8,7 @@ import { discoverAll, Printer, findDeviceByName, + mmToPx, } from "@thermoprint/core"; import type { BlePeripheral, PrintOptions, DitherMode } from "@thermoprint/core"; import { NobleBleTransport } from "../../transport/noble.js"; @@ -68,7 +69,12 @@ export function registerPrintCommands(program: Command): void { // Discover printer const peripheral = await findPrinter(transport, printerName, timeout); const profile = findDeviceByName(peripheral.name); - const printWidth = parseInt(opts.width) || config.width || 384; + // Printers whose head spans the label width (P1) take the image as-is, + // sized to the default label width instead of the 384px head width + const labelConfig = profile?.labelConfig; + const rotateLabel = labelConfig?.rotate ?? true; + const defaultWidth = labelConfig && !rotateLabel ? mmToPx(labelConfig.defaultSize.widthMm) : 384; + const printWidth = parseInt(opts.width) || config.width || defaultWidth; if (spinner) spinner.text = `Connecting to ${peripheral.name}...`; @@ -78,7 +84,7 @@ export function registerPrintCommands(program: Command): void { if (spinner) spinner.text = "Loading image..."; // Load image - const image = await loadImage(imagePath, printWidth, { rotate: opts.rotate }); + const image = await loadImage(imagePath, printWidth, { rotate: opts.rotate && rotateLabel }); // Build print options const printOpts: PrintOptions = {}; diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 3f2011d..0146754 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -10,5 +10,5 @@ export const DEFAULT_CONFIG: Config = { density: 2, paperType: "gap", timeout: 5000, - width: 384, + // No default width: print falls back to the device's label width (384px head width for tape printers) }; diff --git a/packages/core/src/device/profiles/p1.ts b/packages/core/src/device/profiles/p1.ts new file mode 100644 index 0000000..737371e --- /dev/null +++ b/packages/core/src/device/profiles/p1.ts @@ -0,0 +1,36 @@ +import type { DeviceProfile, LabelSizePreset } from "../types.js"; + +// Width runs across the print head, height along the feed (labelConfig.rotate = false) +const gapSizes: LabelSizePreset[] = [ + { widthMm: 30, heightMm: 20 }, + { widthMm: 40, heightMm: 30 }, + { widthMm: 40, heightMm: 40 }, + { widthMm: 40, heightMm: 50 }, + { widthMm: 40, heightMm: 60 }, +]; + +export const p1Profile: DeviceProfile = { + modelId: "p1", + protocolId: "tspl", + serviceUuid: "0000ff00-0000-1000-8000-00805f9b34fb", + characteristics: { + tx: "0000ff02-0000-1000-8000-00805f9b34fb", + rx: "0000ff01-0000-1000-8000-00805f9b34fb", + cx: "0000ff03-0000-1000-8000-00805f9b34fb", + }, + // The printer advertises MTU 182 but silently drops writes larger than ~100 bytes + packetSize: 100, + flowControl: { + packetDelayMs: 30, + }, + defaults: { density: 2, paperType: "gap" }, + namePrefixes: ["P1_"], + labelConfig: { + supportedPaperTypes: ["gap"], + defaultPaperType: "gap", + gapSizes, + continuousSizes: [], + defaultSize: { widthMm: 40, heightMm: 30 }, + rotate: false, + }, +}; diff --git a/packages/core/src/device/registry.ts b/packages/core/src/device/registry.ts index ae7601e..4efc48c 100644 --- a/packages/core/src/device/registry.ts +++ b/packages/core/src/device/registry.ts @@ -2,6 +2,7 @@ import type { DeviceProfile } from "./types.js"; import { p15Profile } from "./profiles/p15.js"; import { p12Profile } from "./profiles/p12.js"; import { m60Profile } from "./profiles/m60.js"; +import { p1Profile } from "./profiles/p1.js"; const devices: DeviceProfile[] = []; @@ -32,3 +33,4 @@ export function getRegisteredDevices(): DeviceProfile[] { registerDevice(p15Profile); registerDevice(p12Profile); registerDevice(m60Profile); +registerDevice(p1Profile); diff --git a/packages/core/src/device/types.ts b/packages/core/src/device/types.ts index a3b1dc1..a3c9436 100644 --- a/packages/core/src/device/types.ts +++ b/packages/core/src/device/types.ts @@ -14,6 +14,12 @@ export interface DeviceLabelConfig { gapSizes: LabelSizePreset[]; continuousSizes: LabelSizePreset[]; defaultSize: LabelSizePreset; + /** + * Rotate designs 90° CW before printing. True for tape printers (P15, P12) whose + * label width runs along the feed; false when the label width spans the print + * head (P1). Default: true. + */ + rotate?: boolean; } export interface DeviceProfile { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 229b682..113c5b5 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -14,6 +14,7 @@ export enum ErrorCode { LOW_BATTERY = "LOW_BATTERY", FLOW_CONTROL_TIMEOUT = "FLOW_CONTROL_TIMEOUT", INVALID_IMAGE = "INVALID_IMAGE", + NOT_SUPPORTED = "NOT_SUPPORTED", } export class ThermoprintError extends Error { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 49bcea1..131b865 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export type { export { registerProtocol, getProtocol } from "./protocol/registry.js"; export { L11Protocol } from "./protocol/l11/protocol.js"; export { X2Protocol } from "./protocol/x2/protocol.js"; +export { TsplProtocol } from "./protocol/tspl/protocol.js"; // Device types & registry (for adding new printer models) export type { diff --git a/packages/core/src/printer.ts b/packages/core/src/printer.ts index f6b1f05..8925698 100644 --- a/packages/core/src/printer.ts +++ b/packages/core/src/printer.ts @@ -199,9 +199,13 @@ export class Printer { this.emit("progress", { bytesSent: baseOffset + preambleBytes + sent, totalBytes }); }); - debugLog("PRINT", `copy ${copy + 1}/${copies}: data sent, waiting for result`); - await this.waitForPrintResult(); - debugLog("PRINT", `copy ${copy + 1}/${copies} done`); + if (this.protocol.confirmsPrint === false) { + debugLog("PRINT", `copy ${copy + 1}/${copies}: data sent (printer sends no confirmation)`); + } else { + debugLog("PRINT", `copy ${copy + 1}/${copies}: data sent, waiting for result`); + await this.waitForPrintResult(); + debugLog("PRINT", `copy ${copy + 1}/${copies} done`); + } } debugLog("PRINT", "all copies done"); } finally { @@ -224,9 +228,11 @@ export class Printer { }; } + /** Battery level 0-100, or -1 if the printer can't report it */ async getBattery(): Promise { if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query battery while printing"); const cmd = this.protocol.buildBatteryQuery(); + if (!cmd) return -1; await this.flowController.send(cmd.data); const response = await this.waitForResponse("battery", 3000); // Battery is in response[1] as a raw byte (0-100) @@ -247,6 +253,7 @@ export class Printer { async getInfo(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): Promise { if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query info while printing"); const cmd = this.protocol.buildInfoQuery(type); + if (!cmd) throw new ThermoprintError(ErrorCode.NOT_SUPPORTED, `Printer does not report ${type}`); await this.flowController.send(cmd.data); const response = await this.waitForResponse(type, 3000); if (typeof response.value === "string" && response.value) return response.value; diff --git a/packages/core/src/protocol/registry.ts b/packages/core/src/protocol/registry.ts index 591a320..45c016c 100644 --- a/packages/core/src/protocol/registry.ts +++ b/packages/core/src/protocol/registry.ts @@ -1,6 +1,7 @@ import type { PrinterProtocol } from "./types.js"; import { L11Protocol } from "./l11/protocol.js"; import { X2Protocol } from "./x2/protocol.js"; +import { TsplProtocol } from "./tspl/protocol.js"; import { ThermoprintError, ErrorCode } from "../errors.js"; type ProtocolFactory = () => PrinterProtocol; @@ -29,3 +30,4 @@ export function getRegisteredProtocolIds(): string[] { // Register built-in protocols registerProtocol("l11", () => new L11Protocol()); registerProtocol("x2", () => new X2Protocol()); +registerProtocol("tspl", () => new TsplProtocol()); diff --git a/packages/core/src/protocol/tspl/commands.ts b/packages/core/src/protocol/tspl/commands.ts new file mode 100644 index 0000000..bcf2e5f --- /dev/null +++ b/packages/core/src/protocol/tspl/commands.ts @@ -0,0 +1,76 @@ +import type { ImageBitmap1bpp, PrintCommand } from "../types.js"; + +const encoder = new TextEncoder(); + +/** TSPL commands are ASCII lines terminated with CRLF */ +function line(text: string): Uint8Array { + return encoder.encode(`${text}\r\n`); +} + +/** Label size: SIZE mm, mm */ +export function size(widthMm: number, heightMm: number): PrintCommand { + return { label: "size", data: line(`SIZE ${widthMm} mm,${heightMm} mm`) }; +} + +/** Gap between labels: GAP 2 mm,0 mm (gap paper) or GAP 0 mm,0 mm (continuous) */ +export function gap(paperType: "gap" | "continuous"): PrintCommand { + const gapMm = paperType === "gap" ? 2 : 0; + return { label: "gap", data: line(`GAP ${gapMm} mm,0 mm`) }; +} + +/** Print direction: DIRECTION 0 (first bitmap row comes out of the printer first) */ +export function direction(): PrintCommand { + return { label: "direction", data: line("DIRECTION 0") }; +} + +/** + * Set density: DENSITY <0-15> + * Density mapping: 1→4, 2→8, 3→12 + */ +export function setDensity(density: number): PrintCommand { + const DENSITY_MAP: Record = { 1: 4, 2: 8, 3: 12 }; + const value = DENSITY_MAP[density] ?? 8; + return { label: "set-density", data: line(`DENSITY ${value}`) }; +} + +/** Clear the image buffer: CLS */ +export function clear(): PrintCommand { + return { label: "clear", data: line("CLS") }; +} + +/** + * Bitmap at x dots from the left edge: BITMAP ,0,,,0, + * TSPL bitmaps are inverted compared to ESC/POS: a 0 bit prints a black dot. + */ +export function printBitmap(image: ImageBitmap1bpp, x: number): PrintCommand { + const { data: pixels, bytesPerRow, height } = image; + const header = encoder.encode(`BITMAP ${x},0,${bytesPerRow},${height},0,`); + const trailer = encoder.encode("\r\n"); + + const command = new Uint8Array(header.length + pixels.length + trailer.length); + command.set(header, 0); + for (let i = 0; i < pixels.length; i++) { + command[header.length + i] = ~pixels[i] & 0xff; + } + command.set(trailer, header.length + pixels.length); + + return { label: "print-bitmap", data: command, bulk: true }; +} + +/** Print one copy of the buffer: PRINT 1,1 */ +export function print(): PrintCommand { + return { label: "print", data: line("PRINT 1,1") }; +} + +/** Immediate status query: ESC ! ? — answered with a single status byte */ +export function getStatus(): PrintCommand { + return { label: "get-status", data: Uint8Array.from([0x1b, 0x21, 0x3f]) }; +} + +/** ESC/POS GS I — transmit printer ID (n: 0x41 firmware, 0x43 name, 0x44 serial) */ +export function getPrinterId(n: number): PrintCommand { + return { + label: "get-printer-id", + data: Uint8Array.from([0x1d, 0x49, n & 0xff]), + }; +} diff --git a/packages/core/src/protocol/tspl/protocol.ts b/packages/core/src/protocol/tspl/protocol.ts new file mode 100644 index 0000000..6ac82de --- /dev/null +++ b/packages/core/src/protocol/tspl/protocol.ts @@ -0,0 +1,133 @@ +import type { + ImageBitmap1bpp, + PrintCommand, + PrinterProtocol, + PrinterResponse, + PrintSequenceOptions, +} from "../types.js"; +import * as cmd from "./commands.js"; +import { PX_PER_MM } from "../../utils/px-mm.js"; + +/** + * P1 print head geometry. SIZE spans the whole 48 mm head and the image is + * placed with BITMAP x. Labels are loaded against the right paper guide, + * which sits ~1 mm past the end of the head, so the image is aligned to the + * guide and its last ~1 mm falls outside the printable area. + */ +const HEAD_WIDTH_DOTS = 384; +const PAPER_GUIDE_DOTS = 392; + +/** Left edge (in dots) that puts the image against the right paper guide */ +function imageX(image: ImageBitmap1bpp): number { + return Math.max(0, PAPER_GUIDE_DOTS - image.bytesPerRow * 8); +} + +/** Async error frame pushed by the printer on RX: "err:" <4 bytes>, flags in the 3rd byte */ +const ERR_MARKER = [0x65, 0x72, 0x72, 0x3a]; +const ERR_FLAGS_OFFSET = ERR_MARKER.length + 2; + +/** + * Status bits shared by the ESC ! ? reply and the "err:" frame (TSPL spec bit layout). + * The P1 paper sensor reports an open lid as out-of-paper (0x04). + */ +function statusFromFlags(flags: number): string { + if (flags === 0) return "ready"; + if (flags & 0x41) return "cover_open"; + if (flags & 0x04) return "out_of_paper"; + if (flags & 0x02) return "paper_jam"; + if (flags & 0x20) return "printing"; + if (flags & 0x10) return "paused"; + if (flags & 0x80) return "error"; + return `unknown_${flags.toString(16)}`; +} + +function indexOfErrFrame(data: Uint8Array): number { + for (let i = 0; i + ERR_FLAGS_OFFSET < data.length; i++) { + if (ERR_MARKER.every((b, j) => data[i + j] === b)) return i; + } + return -1; +} + +/** + * TSPL label protocol, as spoken by the P1 mini label printer. + * + * The BLE transport is the same as Marklife printers (ff00 service, credits + * and MTU on CX), but the print job is plain TSPL text with a raw bitmap. + * The printer never acknowledges a finished job, so `confirmsPrint` is false. + */ +export class TsplProtocol implements PrinterProtocol { + readonly id = "tspl"; + readonly confirmsPrint = false; + + buildPrintSequence( + image: ImageBitmap1bpp, + options: PrintSequenceOptions = {}, + ): PrintCommand[] { + const { density, paperType = "gap" } = options; + const commands: PrintCommand[] = [ + cmd.size(HEAD_WIDTH_DOTS / PX_PER_MM, Math.ceil(image.height / PX_PER_MM)), + cmd.gap(paperType), + cmd.direction(), + ]; + + if (density !== undefined) { + commands.push(cmd.setDensity(density)); + } + + commands.push(cmd.clear()); + commands.push(cmd.printBitmap(image, imageX(image))); + commands.push(cmd.print()); + return commands; + } + + buildWakeup(): PrintCommand[] { + return []; + } + + buildStatusQuery(): PrintCommand { + return cmd.getStatus(); + } + + buildBatteryQuery(): PrintCommand | null { + return null; + } + + /** The P1 answers GS I 0x41 with "P1-6044" (model + firmware build) */ + buildModelQuery(): PrintCommand { + return cmd.getPrinterId(0x41); + } + + buildInfoQuery(): PrintCommand | null { + return null; + } + + parseResponse(data: Uint8Array): PrinterResponse | null { + if (data.length < 1) return null; + + const first = data[0]; + + // Status reply to ESC ! ?: a single flags byte + if (data.length === 1) { + return { type: "status", raw: data, value: statusFromFlags(first) }; + } + + // Credit grant on CX: [0x01, count] + if (first === 0x01 && data.length === 2) { + return { type: "credit", raw: data, value: data[1] }; + } + + // MTU notification on CX: [0x02, mtu_lo, mtu_hi] + if (first === 0x02 && data.length === 3) { + const mtu = (data[2] << 8) | data[1]; + return { type: "mtu", raw: data, value: mtu }; + } + + // Async error frame: "err:" 14 00 0F — sent when the lid opens/closes + const err = indexOfErrFrame(data); + if (err !== -1) { + return { type: "status", raw: data, value: statusFromFlags(data[err + ERR_FLAGS_OFFSET]) }; + } + + return null; + } +} diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index 6f27a3a..556f623 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -40,14 +40,21 @@ export interface ImageBitmap1bpp { export interface PrinterProtocol { readonly id: string; + /** + * Whether the printer reports a finished job with a "success" response. + * When false, print() resolves as soon as all data has been sent. Default: true. + */ + readonly confirmsPrint?: boolean; buildPrintSequence( image: ImageBitmap1bpp, options?: PrintSequenceOptions, ): PrintCommand[]; buildWakeup(): PrintCommand[]; buildStatusQuery(): PrintCommand; - buildBatteryQuery(): PrintCommand; + /** Returns null if the printer has no battery query */ + buildBatteryQuery(): PrintCommand | null; buildModelQuery(): PrintCommand; - buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand; + /** Returns null if the printer can't report this info type */ + buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand | null; parseResponse(data: Uint8Array): PrinterResponse | null; } diff --git a/packages/core/test/protocol/tspl.test.ts b/packages/core/test/protocol/tspl.test.ts new file mode 100644 index 0000000..79a0d62 --- /dev/null +++ b/packages/core/test/protocol/tspl.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import * as cmd from "../../src/protocol/tspl/commands"; +import { TsplProtocol } from "../../src/protocol/tspl/protocol"; + +const text = (data: Uint8Array) => new TextDecoder().decode(data); + +// 40×30 mm label at 8 px/mm +const image = { + data: new Uint8Array(40 * 240), + width: 320, + height: 240, + bytesPerRow: 40, +}; + +describe("TSPL commands", () => { + test("gap is 2 mm for gap paper and 0 for continuous", () => { + expect(text(cmd.gap("gap").data)).toBe("GAP 2 mm,0 mm\r\n"); + expect(text(cmd.gap("continuous").data)).toBe("GAP 0 mm,0 mm\r\n"); + }); + + test("setDensity maps 1-3 onto the 0-15 scale", () => { + expect(text(cmd.setDensity(1).data)).toBe("DENSITY 4\r\n"); + expect(text(cmd.setDensity(2).data)).toBe("DENSITY 8\r\n"); + expect(text(cmd.setDensity(3).data)).toBe("DENSITY 12\r\n"); + }); + + test("printBitmap inverts pixels (0 = black) and wraps them in BITMAP ... CRLF", () => { + const img = { data: Uint8Array.from([0xff, 0x00, 0x0f]), width: 8, height: 3, bytesPerRow: 1 }; + const { data, bulk } = cmd.printBitmap(img, 12); + const header = "BITMAP 12,0,1,3,0,"; + expect(bulk).toBe(true); + expect(text(data.subarray(0, header.length))).toBe(header); + expect(Array.from(data.subarray(header.length, header.length + 3))).toEqual([0x00, 0xff, 0xf0]); + expect(text(data.subarray(header.length + 3))).toBe("\r\n"); + }); + + test("getStatus is ESC ! ?", () => { + expect(Array.from(cmd.getStatus().data)).toEqual([0x1b, 0x21, 0x3f]); + }); +}); + +describe("TsplProtocol", () => { + const protocol = new TsplProtocol(); + + test("print sequence sets up the label before the bitmap", () => { + const labels = protocol.buildPrintSequence(image, { density: 2 }).map((c) => c.label); + expect(labels).toEqual(["size", "gap", "direction", "set-density", "clear", "print-bitmap", "print"]); + }); + + test("spans the whole head and puts the image against the right paper guide", () => { + const [size, , , , , bitmap] = protocol.buildPrintSequence(image, { density: 2 }); + expect(text(size.data)).toBe("SIZE 48 mm,30 mm\r\n"); + // Paper guide at 392 dots - 320-dot image + expect(text(bitmap.data.subarray(0, 21))).toBe("BITMAP 72,0,40,240,0,"); + }); + + test("does not wait for a print confirmation", () => { + expect(protocol.confirmsPrint).toBe(false); + }); + + test("parses a single status byte", () => { + expect(protocol.parseResponse(Uint8Array.from([0x00]))?.value).toBe("ready"); + expect(protocol.parseResponse(Uint8Array.from([0x04]))?.value).toBe("out_of_paper"); + }); + + test("parses credits and MTU", () => { + expect(protocol.parseResponse(Uint8Array.from([0x01, 0x0a]))).toMatchObject({ type: "credit", value: 10 }); + expect(protocol.parseResponse(Uint8Array.from([0x02, 0xb6, 0x00]))).toMatchObject({ type: "mtu", value: 182 }); + }); + + test("parses the async err: frame sent when the lid opens and closes", () => { + const opened = Uint8Array.from([0x65, 0x72, 0x72, 0x3a, 0x14, 0x00, 0x0c, 0x0f]); + const closed = Uint8Array.from([0x65, 0x72, 0x72, 0x3a, 0x14, 0x00, 0x00, 0x0f]); + expect(protocol.parseResponse(opened)).toMatchObject({ type: "status", value: "out_of_paper" }); + expect(protocol.parseResponse(closed)).toMatchObject({ type: "status", value: "ready" }); + // Can arrive glued to a preceding reply byte in the same notification + expect(protocol.parseResponse(Uint8Array.from([0x12, ...opened]))?.value).toBe("out_of_paper"); + }); + + test("leaves string replies to the pending query", () => { + expect(protocol.parseResponse(new TextEncoder().encode("P1-6044"))).toBeNull(); + }); +}); diff --git a/packages/web/src/editor/editor.tsx b/packages/web/src/editor/editor.tsx index 84492c4..6923129 100644 --- a/packages/web/src/editor/editor.tsx +++ b/packages/web/src/editor/editor.tsx @@ -11,7 +11,8 @@ import { ConnectFlow } from "./connect-flow/connect-flow.tsx"; import { useKeyboardShortcuts, setPrintFn } from "../lib/keyboard.ts"; import { useEditorV2Store } from "../store/editor-store.ts"; import { getPrinter } from "../hooks/use-web-bluetooth.ts"; -import type { RawImageData } from "@thermoprint/core"; +import { usePrinterStore } from "../store/printer-store.ts"; +import { getDevice, type RawImageData } from "@thermoprint/core"; function captureLabel( stage: Konva.Stage, @@ -89,9 +90,12 @@ export function Editor() { // Wait a frame for Konva to re-render without selection handles await new Promise((r) => requestAnimationFrame(r)); - // Capture the label region at 1:1 pixel resolution + // Capture the label region at 1:1 pixel resolution. Tape printers feed the + // label's width along the paper, so the design is rotated to match the head. const raw = captureLabel(stage, label.widthPx, label.heightPx); - const canvas = rotateCanvas90CW(raw); + const { modelId } = usePrinterStore.getState(); + const rotate = getDevice(modelId ?? "")?.labelConfig?.rotate ?? true; + const canvas = rotate ? rotateCanvas90CW(raw) : raw; const rotatedW = canvas.width; const rotatedH = canvas.height; diff --git a/packages/web/src/editor/top-chrome/printer-chip.tsx b/packages/web/src/editor/top-chrome/printer-chip.tsx index 4106113..0c5dd11 100644 --- a/packages/web/src/editor/top-chrome/printer-chip.tsx +++ b/packages/web/src/editor/top-chrome/printer-chip.tsx @@ -15,7 +15,7 @@ const STATUS_LABELS: Record = { }; function StatusBadge({ status }: { status: string | null }) { - if (!status || status === "cover_closed") { + if (!status || status === "ready" || status === "cover_closed") { return (
diff --git a/packages/web/src/store/printer-store.ts b/packages/web/src/store/printer-store.ts index e269ef4..5f39743 100644 --- a/packages/web/src/store/printer-store.ts +++ b/packages/web/src/store/printer-store.ts @@ -56,6 +56,8 @@ export function applyModelDefaults(modelId: string): void { const { widthMm, heightMm } = lc.defaultSize; useEditorStore.setState({ label: { widthMm, heightMm, widthPx: mmToPx(widthMm), heightPx: mmToPx(heightMm) }, + // Preview the roll the way it feeds: along the width for rotated (tape) printers + rollDirection: lc.rotate === false ? "vertical" : "horizontal", }); }