If your printer speaks the same protocol as an existing one (e.g., another Marklife variant using the L11 protocol), you only need a DeviceProfile object.
// src/device/profiles/p20.ts
import type { DeviceProfile } from "../types.js";
export const p20Profile: DeviceProfile = {
modelId: "p20",
protocolId: "l11", // reuse existing protocol
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", // optional — omit if no flow control char
},
packetSize: 95, // BLE write chunk size in bytes
flowControl: {
initialCredits: 4,
starvationTimeoutMs: 1000,
timerIntervalMs: 30,
},
defaults: { density: 2, paperType: "gap" }, // "gap" for label paper, "continuous" for receipt
namePrefixes: ["P20", "P20S"], // BLE advertised name prefixes
};Add it to src/device/registry.ts:
import { p20Profile } from "./profiles/p20.js";
registerDevice(p20Profile);That's it. discover() will now match peripherals whose name starts with "P20" or "P20S", and Printer.connect() will use the L11 protocol with your profile's settings.
| Field | Type | Description |
|---|---|---|
modelId |
string |
Unique identifier (e.g., "p15", "p12") |
protocolId |
string |
Must match a registered protocol (e.g., "l11") |
serviceUuid |
string |
GATT service UUID |
characteristics.tx |
string |
Write characteristic UUID |
characteristics.rx |
string |
Notify characteristic for responses |
characteristics.cx |
string? |
Notify characteristic for flow control credits (optional) |
packetSize |
number? |
Max bytes per BLE write (default: 237) |
flowControl |
Partial<FlowControlOptions> |
Override initialCredits, starvationTimeoutMs, timerIntervalMs |
defaults.density |
number |
Default print density (0-3) |
defaults.paperType |
"gap" | "continuous" |
Default paper type |
namePrefixes |
string[] |
BLE name prefixes to match during discovery |
findDeviceByName(name) iterates all registered profiles and checks if name.startsWith(prefix) for any prefix in namePrefixes. First match wins.
// Internal logic (src/device/registry.ts):
for (const profile of devices) {
for (const prefix of profile.namePrefixes) {
if (name.startsWith(prefix)) return profile;
}
}
return null;Tip: Be specific with prefixes. "P1" would match "P15", "P12", and "P1s". If models need different profiles, use longer prefixes like "P15" and "P12".
You can also register devices from consumer code without modifying the library:
import { registerDevice } from "@thermoprint/core";
registerDevice({
modelId: "custom-printer",
protocolId: "l11",
serviceUuid: "0000ff00-0000-1000-8000-00805f9b34fb",
characteristics: { tx: "...", rx: "..." },
flowControl: {},
defaults: { density: 2, paperType: "continuous" },
namePrefixes: ["CUSTOM_"],
});If your printer uses a different command format, you need to implement PrinterProtocol.
// src/protocol/myproto/protocol.ts
import type {
PrinterProtocol,
PrintCommand,
PrinterResponse,
PrintSequenceOptions,
ImageBitmap1bpp,
} from "../types.js";
export class MyProtocol implements PrinterProtocol {
readonly id = "myproto";
buildPrintSequence(image: ImageBitmap1bpp, options?: PrintSequenceOptions): PrintCommand[] {
// Return an array of PrintCommand objects.
// Each command is { label: string, data: Uint8Array, bulk?: boolean }.
// Set bulk: true on the bitmap data command — this enables progress tracking.
return [
{ label: "init", data: Uint8Array.from([0x01, 0x02]) },
{ label: "bitmap", data: this.encodeBitmap(image), bulk: true },
{ label: "finish", data: Uint8Array.from([0x03]) },
];
}
buildWakeup(): PrintCommand[] {
return [{ label: "wakeup", data: new Uint8Array(10) }];
}
buildStatusQuery(): PrintCommand {
return { label: "status", data: Uint8Array.from([0x10]) };
}
buildBatteryQuery(): PrintCommand {
return { label: "battery", data: Uint8Array.from([0x11]) };
}
parseResponse(data: Uint8Array): PrinterResponse | null {
// Parse incoming BLE notifications into typed responses.
// Return null for unrecognized data.
if (data[0] === 0x01) return { type: "credit", raw: data, value: data[1] };
if (data[0] === 0xAA) return { type: "success", raw: data };
return null;
}
private encodeBitmap(image: ImageBitmap1bpp): Uint8Array {
// Protocol-specific bitmap encoding
// ...
}
}Add it to src/protocol/registry.ts:
import { MyProtocol } from "./myproto/protocol.js";
registerProtocol("myproto", () => new MyProtocol());Or register at runtime from consumer code:
import { registerProtocol } from "@thermoprint/core";
import { MyProtocol } from "./my-protocol.js";
registerProtocol("myproto", () => new MyProtocol());registerDevice({
modelId: "new-printer",
protocolId: "myproto", // references your registered protocol
// ...
});The registry stores factories, not instances:
type ProtocolFactory = () => PrinterProtocol;
const protocols = new Map<string, ProtocolFactory>();getProtocol(id) calls the factory each time, so every Printer.connect() gets its own protocol instance. This avoids shared state between connections.
There are no public specs for these printers. Reverse-engineer by:
- BLE sniffing. Use nRF Connect (mobile) or Wireshark with an HCI log to capture BLE traffic from the official app.
- APK decompilation. Decompile the manufacturer's Android app (e.g., with jadx) and look for BLE write calls.
- The existing L11 protocol.
src/protocol/l11/commands.tshas annotated command bytes — use it as a reference for the command structure pattern.
Your parseResponse() should return responses with these type values as appropriate:
| Type | Purpose | value |
|---|---|---|
"credit" |
Flow control credit grant | number (credit count) |
"mtu" |
MTU negotiation | number (MTU value) |
"status" |
Printer status change | string (e.g., "out_of_paper") |
"success" |
Print completed | — |
"error" |
Print failed | — |
"battery" |
Battery level response | number |
"model" |
Model string response | string |
"firmware" |
Firmware version response | string |
"serial" |
Serial number response | string |
"mac" |
MAC address response | string |
The "credit" type is critical — without it, flow control will rely entirely on starvation recovery (slow). The "success" type is also important — Printer.print() waits for it before resolving.