Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<img src="https://img.shields.io/badge/Marklife_P15-supported-009688.svg" alt="Marklife P15">
<img src="https://img.shields.io/badge/Marklife_P12-supported-009688.svg" alt="Marklife P12">
<img src="https://img.shields.io/badge/Marklife_P7-supported-009688.svg" alt="Marklife P7">
<img src="https://img.shields.io/badge/P1_Mini-supported-009688.svg" alt="P1 Mini">
<img src="https://img.shields.io/badge/L11_protocol-compatible-lightgrey.svg" alt="L11 Compatible">
</p>

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/adding-a-printer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/cli/commands/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}...`;

Expand All @@ -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 = {};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
36 changes: 36 additions & 0 deletions packages/core/src/device/profiles/p1.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
2 changes: 2 additions & 0 deletions packages/core/src/device/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -32,3 +33,4 @@ export function getRegisteredDevices(): DeviceProfile[] {
registerDevice(p15Profile);
registerDevice(p12Profile);
registerDevice(m60Profile);
registerDevice(p1Profile);
6 changes: 6 additions & 0 deletions packages/core/src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -224,9 +228,11 @@ export class Printer {
};
}

/** Battery level 0-100, or -1 if the printer can't report it */
async getBattery(): Promise<number> {
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)
Expand All @@ -247,6 +253,7 @@ export class Printer {
async getInfo(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): Promise<string> {
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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/protocol/registry.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -29,3 +30,4 @@ export function getRegisteredProtocolIds(): string[] {
// Register built-in protocols
registerProtocol("l11", () => new L11Protocol());
registerProtocol("x2", () => new X2Protocol());
registerProtocol("tspl", () => new TsplProtocol());
76 changes: 76 additions & 0 deletions packages/core/src/protocol/tspl/commands.ts
Original file line number Diff line number Diff line change
@@ -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 <w> mm,<h> 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<number, number> = { 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 <x>,0,<bytesPerRow>,<height>,0,<data>
* 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 <n> — 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]),
};
}
Loading