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 @@
+
@@ -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",
});
}