The library communicates with printers through this interface. You implement it once for your BLE stack, then pass it to discover() and Printer.connect().
interface BleTransport {
scan(
onDiscover: (peripheral: BlePeripheral) => void,
options?: ScanOptions,
): Promise<ScanHandle>;
connect(peripheral: BlePeripheral): Promise<BleConnection>;
}
interface BlePeripheral {
id: string; // Platform-specific identifier (MAC, UUID, etc.)
name: string; // Advertised local name β used for device matching
rssi: number;
}
interface ScanOptions {
timeoutMs?: number;
namePrefix?: string;
}
interface ScanHandle {
stop(): Promise<void>;
}
interface BleConnection {
discoverService(uuid: string): Promise<BleService | null>;
disconnect(): Promise<void>;
readonly isConnected: boolean;
}
interface BleService {
getCharacteristic(uuid: string): Promise<BleCharacteristic | null>;
}
interface BleCharacteristic {
write(data: Uint8Array, withoutResponse: boolean): Promise<void>;
subscribe(listener: (data: Uint8Array) => void): Promise<void>;
unsubscribe(): Promise<void>;
}Step by step:
-
scan()β Start a BLE scan. CallonDiscoverfor each peripheral found. Return aScanHandlewhosestop()halts scanning. The library callsstop()when it finds a match or times out. -
connect()β Establish a BLE connection to the given peripheral. Return aBleConnection. -
discoverService(uuid)β After connecting, the library calls this with the printer's service UUID (e.g.0000ff00-0000-1000-8000-00805f9b34fb). Discover GATT services and return the matching one, ornull. -
getCharacteristic(uuid)β Return the characteristic for the given UUID, ornull. The library uses three characteristics:- TX (
ff02) β write commands to the printer - RX (
ff01) β subscribe for responses (status, print success) - CX (
ff03) β subscribe for flow control credits
- TX (
-
write(data, withoutResponse)β Write bytes to the characteristic. The library always passeswithoutResponse = truefor TX. -
subscribe(listener)β Register a notification listener. The library subscribes to RX and CX. -
unsubscribe()β Remove the notification listener.
import noble from "@abandonware/noble";
import type {
BleTransport,
BleConnection,
BleService,
BleCharacteristic,
BlePeripheral,
ScanHandle,
ScanOptions,
} from "@thermoprint/core";
export function createNobleTransport(): BleTransport {
return {
async scan(onDiscover, options?: ScanOptions): Promise<ScanHandle> {
const uuids = options?.namePrefix ? [] : [];
noble.on("discover", (p) => {
const name = p.advertisement.localName ?? "";
if (options?.namePrefix && !name.startsWith(options.namePrefix)) return;
onDiscover({ id: p.uuid, name, rssi: p.rssi });
});
await noble.startScanningAsync(uuids, true);
return {
async stop() {
await noble.stopScanningAsync();
noble.removeAllListeners("discover");
},
};
},
async connect(peripheral: BlePeripheral): Promise<BleConnection> {
// Re-discover the noble peripheral by ID
const noblePeripheral = noble._peripherals[peripheral.id];
await noblePeripheral.connectAsync();
const { services } = await noblePeripheral.discoverAllServicesAndCharacteristicsAsync();
return {
get isConnected() { return noblePeripheral.state === "connected"; },
async disconnect() { await noblePeripheral.disconnectAsync(); },
async discoverService(uuid: string): Promise<BleService | null> {
const svc = services.find((s) => s.uuid === uuid.replace(/-/g, ""));
if (!svc) return null;
return {
async getCharacteristic(charUuid: string): Promise<BleCharacteristic | null> {
const ch = svc.characteristics.find(
(c) => c.uuid === charUuid.replace(/-/g, ""),
);
if (!ch) return null;
return {
async write(data, withoutResponse) {
await ch.writeAsync(Buffer.from(data), withoutResponse);
},
async subscribe(listener) {
ch.on("data", (buf: Buffer) => listener(new Uint8Array(buf)));
await ch.subscribeAsync();
},
async unsubscribe() {
ch.removeAllListeners("data");
await ch.unsubscribeAsync();
},
};
},
};
},
};
},
};
}import type {
BleTransport,
BleConnection,
BleService,
BleCharacteristic,
BlePeripheral,
ScanHandle,
ScanOptions,
} from "@thermoprint/core";
export function createWebBluetoothTransport(): BleTransport {
return {
async scan(onDiscover, options?: ScanOptions): Promise<ScanHandle> {
// Web Bluetooth uses a picker dialog β no continuous scan.
// requestDevice returns a single device chosen by the user.
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [0xff00] }],
optionalServices: [0xff00],
});
onDiscover({
id: device.id,
name: device.name ?? "Unknown",
rssi: 0,
});
// Store device for connect() to retrieve
(this as any)._device = device;
return { async stop() {} };
},
async connect(peripheral: BlePeripheral): Promise<BleConnection> {
const device: BluetoothDevice = (this as any)._device;
const server = await device.gatt!.connect();
return {
get isConnected() { return server.connected; },
async disconnect() { server.disconnect(); },
async discoverService(uuid: string): Promise<BleService | null> {
try {
const svc = await server.getPrimaryService(uuid);
return {
async getCharacteristic(charUuid: string): Promise<BleCharacteristic | null> {
try {
const ch = await svc.getCharacteristic(charUuid);
return {
async write(data, withoutResponse) {
if (withoutResponse) {
await ch.writeValueWithoutResponse(data);
} else {
await ch.writeValueWithResponse(data);
}
},
async subscribe(listener) {
ch.addEventListener("characteristicvaluechanged", (e) => {
const value = (e.target as BluetoothRemoteGATTCharacteristic).value!;
listener(new Uint8Array(value.buffer));
});
await ch.startNotifications();
},
async unsubscribe() {
await ch.stopNotifications();
},
};
} catch { return null; }
},
};
} catch { return null; }
},
};
},
};
}Thermoprint uses credit-based flow control to avoid overwhelming the printer's BLE buffer. This is handled automatically by FlowController β you don't need to implement it in your transport.
-
Credits start at zero. The
FlowControllerstarts with no credits. The printer grants the initial credits (typically 4) via a CX notification after connection. Each credit allows sending one packet. -
Timer-based sending. Data is sent on a periodic timer matching the official Marklife app's approach. The timer interval is
packetDelayMs(P15: 30ms, default: 30ms). On each tick, at most one packet is sent if credits are available. -
Packet chunking. Data is split into chunks of
packetSizebytes (P15: 95 bytes, P12: 90 bytes, fallback: 237 = default MTU). Each chunk consumes one credit. -
Credit grants. The printer sends credit notifications on the CX characteristic. The protocol parses
[0x01, count]as a credit grant.FlowController.grantCredits(count)adds them. -
Starvation recovery. If no credit arrives within
starvationTimeoutMs(default: 1000ms), the controller unconditionally forcescredits = 1and continues. This matches the official app's recovery logic and prevents deadlocks caused by lost BLE notifications.
Printer FlowController
β β
β send(data)
β βββ chunk 1 β write(chunk, true)
β βββ chunk 2 β write(chunk, true)
β βββ chunk 3 β write(chunk, true)
β βββ chunk 4 β write(chunk, true)
β β credits = 0, waiting...
β βββ CX notify [0x01, 0x04] βββββββ
β β grantCredits(4)
β βββ chunk 5 β write(chunk, true)
β β ...
-
Noble UUID format. Noble strips hyphens from UUIDs (e.g.,
0000ff0000001000800000805f9b34fb). YourdiscoverService/getCharacteristicwrapper needs to normalize the format. -
Web Bluetooth picker. Web Bluetooth doesn't support continuous scanning.
scan()triggers a user-facing dialog that returns one device. Implement accordingly βdiscoverAll()will only return that single device. -
withoutResponsemust work. The library always writes withwithoutResponse = true. If your BLE stack requires write-with-response for some characteristics, you may need to handle that in your adapter, but the current protocol only uses write-without-response on TX. -
Notification subscriptions are mandatory. The library subscribes to RX (for status/success) and CX (for credits). If subscribe doesn't work, prints will hang waiting for credits or the success acknowledgment.
-
Thread safety.
FlowController.send()is not re-entrant. ThePrinterclass sends commands sequentially. If you bypassPrinterand useFlowControllerdirectly, don't callsend()concurrently.