Skip to content

Instantly share code, notes, and snippets.

@JakubAndrysek
Created July 24, 2026 19:22
Show Gist options
  • Select an option

  • Save JakubAndrysek/caf19af777b460c420612f692e9f16e8 to your computer and use it in GitHub Desktop.

Select an option

Save JakubAndrysek/caf19af777b460c420612f692e9f16e8 to your computer and use it in GitHub Desktop.
TypeScript Web Bluetooth library for GOOJPRT PT210 and MX/MXW01 thermal printers.

Web Bluetooth Thermal Printer

A small, dependency-free TypeScript driver for two BLE thermal-printer protocols:

Profile Verified configuration Print width
PT210_PROFILE GOOJPRT PT210 / MTP-II, service 18F0, write characteristic 2AF1 384 pixels
MXW01_PROFILE MX / MXW01, services AE30 or AF30, characteristics AE01 / AE02 / AE03 384 pixels

The library deliberately handles Bluetooth and printer protocols only. It does not load images, draw to a canvas, dither, or generate text. Convert your content to a one-bit raster first, then call printMonochromeRaster().

Requirements

  • A browser with Web Bluetooth support. Chromium-based desktop browsers are the practical choice.
  • HTTPS or http://localhost.
  • connect() must be called from a user gesture, such as a button click.
  • TypeScript configured with DOM declarations (the library has no package dependencies).

Install

Copy thermal-printer.ts into your project, then import it:

import { ThermalPrinter, PT210_PROFILE, MXW01_PROFILE } from "./thermal-printer";

Basic example

This prints a 384×32 test raster with two black horizontal lines. A raster is row-major and MSB-first: 0b10000000 means a black leftmost pixel.

import { ThermalPrinter } from "./thermal-printer";

const printer = new ThermalPrinter();

document.querySelector<HTMLButtonElement>("#connect")!.addEventListener("click", async () => {
  const profile = await printer.connect();
  console.log(`Connected to ${profile.name}`);

  const width = 384;
  const height = 32;
  const bytesPerRow = width / 8;
  const raster = new Uint8Array(bytesPerRow * height);

  // A black line at the top and at the bottom.
  raster.fill(0xff, 0, bytesPerRow);
  raster.fill(0xff, (height - 1) * bytesPerRow, height * bytesPerRow);

  await printer.printMonochromeRaster(raster, width, height, {
    density: "normal", // Used by MX/MXW01; ignored by PT210.
    feedLines: 12,      // Used by MX/MXW01; ignored by PT210.
  });
});

To advance paper:

await printer.feed(24);

To disconnect:

printer.disconnect();

PT210 notes

The PT210 profile uses the 384-dot MTP-II/ESC-POS raster protocol:

  • Service: 000018f0-0000-1000-8000-00805f9b34fb
  • Write characteristic: 00002af1-0000-1000-8000-00805f9b34fb
  • Raster command: GS v 0 (1D 76 30 00)
  • Data transport: acknowledged 128-byte blocks, which avoids random missing raster blocks that occur with unthrottled BLE writes.

Important: thermal paper orientation

If PT210 thermal paper is loaded with the heat-sensitive side facing the wrong direction, print can appear horizontally mirrored and only about 10% as dark as normal. This is a paper-loading issue, not a Bluetooth transport, raster bit-order, or print-density problem.

Do not compensate by mirroring data or increasing print density. Load the paper with its thermal side facing the print head instead.

MX/MXW01 notes

The MX/MXW01 path preserves the command framing used by the companion web app: it sets density, requests a raster job, sends 48-byte rows through AE03, and flushes the job. It pads output to the printer's 90-row minimum.

density accepts "faded", "normal", and "dark" for MX/MXW01. PT210 does not use undocumented energy/darkness commands, so the option is ignored for that printer.

API

class ThermalPrinter {
  connect(): Promise<PrinterProfile>;
  disconnect(): void;
  printMonochromeRaster(
    raster: Uint8Array,
    width: number,
    height: number,
    options?: { density?: "faded" | "normal" | "dark"; feedLines?: number },
  ): Promise<void>;
  feed(lines?: number): Promise<void>;
  readonly connected: boolean;
  readonly activeProfile: PrinterProfile | null;
  readonly deviceName: string | undefined;
}

Limitations

  • Web Bluetooth is not supported by Safari or Firefox in normal desktop use.
  • The browser always shows its device picker; a website cannot silently connect to a printer.
  • This library does not discover unknown printer protocols. It supports only the two profiles listed above.

Sources and acknowledgment

The PT210 raster setup follows the publicly available AbleTP and BitBank Thermal Printer projects.

/**
* Dependency-free Web Bluetooth driver for GOOJPRT PT210 and MX/MXW01
* thermal printers.
*
* Input rasters are 1-bit, row-major, MSB-first: bit 7 is the leftmost pixel.
*/
export type PrinterKind = "pt210" | "mxw01";
export type PrintDensity = "faded" | "normal" | "dark";
export interface PrinterProfile {
readonly id: PrinterKind;
readonly name: string;
readonly serviceUUIDs: readonly string[];
readonly controlUUID: string;
readonly dataUUID: string;
readonly notifyUUID?: string;
readonly printWidth: number;
}
export interface PrintOptions {
/** Applies to MX/MXW01. PT210 does not use undocumented darkness commands. */
readonly density?: PrintDensity;
/** Extra blank MX/MXW01 rows after the raster. Defaults to 10. */
readonly feedLines?: number;
}
export const PT210_PROFILE: PrinterProfile = {
id: "pt210",
name: "GOOJPRT PT210 / MTP-II",
serviceUUIDs: ["000018f0-0000-1000-8000-00805f9b34fb"],
controlUUID: "00002af1-0000-1000-8000-00805f9b34fb",
dataUUID: "00002af1-0000-1000-8000-00805f9b34fb",
notifyUUID: "00002af0-0000-1000-8000-00805f9b34fb",
printWidth: 384,
};
export const MXW01_PROFILE: PrinterProfile = {
id: "mxw01",
name: "MX / MXW01",
serviceUUIDs: [
"0000ae30-0000-1000-8000-00805f9b34fb",
"0000af30-0000-1000-8000-00805f9b34fb",
],
controlUUID: "0000ae01-0000-1000-8000-00805f9b34fb",
dataUUID: "0000ae03-0000-1000-8000-00805f9b34fb",
notifyUUID: "0000ae02-0000-1000-8000-00805f9b34fb",
printWidth: 384,
};
const PROFILES = [PT210_PROFILE, MXW01_PROFILE] as const;
const sleep = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
type GattDevice = {
name?: string;
gatt?: {
connected: boolean;
connect(): Promise<unknown>;
disconnect(): void;
};
addEventListener?(type: string, listener: EventListener): void;
removeEventListener?(type: string, listener: EventListener): void;
};
/** Web Bluetooth is intentionally structural so this file needs no @types package. */
type BluetoothNavigator = Navigator & {
bluetooth?: {
requestDevice(options: unknown): Promise<GattDevice>;
};
};
/**
* Connects to either supported printer profile and prints pre-packed rasters.
* The browser must call connect() from a user gesture (for example, a click).
*/
export class ThermalPrinter {
private device: GattDevice | null = null;
private server: any = null;
private controlCharacteristic: any = null;
private dataCharacteristic: any = null;
private profile: PrinterProfile | null = null;
private readonly onDisconnected = () => this.clearConnection();
public get connected(): boolean {
return Boolean(this.server?.connected && this.controlCharacteristic && this.dataCharacteristic);
}
public get activeProfile(): PrinterProfile | null {
return this.profile;
}
public get deviceName(): string | undefined {
return this.device?.name;
}
/** Opens the browser device chooser and connects to PT210 or MX/MXW01. */
public async connect(): Promise<PrinterProfile> {
this.disconnect();
const bluetooth = (navigator as BluetoothNavigator).bluetooth;
if (!bluetooth) {
throw new Error("Web Bluetooth is not available in this browser or context.");
}
const serviceUUIDs = PROFILES.flatMap((profile) => [...profile.serviceUUIDs]);
this.device = await bluetooth.requestDevice({
filters: serviceUUIDs.map((services) => ({ services: [services] })),
optionalServices: serviceUUIDs,
});
this.device.addEventListener?.("gattserverdisconnected", this.onDisconnected);
if (!this.device.gatt) throw new Error("The selected Bluetooth device has no GATT server.");
this.server = await this.device.gatt.connect();
for (const candidate of PROFILES) {
for (const serviceUUID of candidate.serviceUUIDs) {
try {
const service = await this.server.getPrimaryService(serviceUUID);
const control = await service.getCharacteristic(candidate.controlUUID);
const data = candidate.dataUUID === candidate.controlUUID
? control
: await service.getCharacteristic(candidate.dataUUID);
if (candidate.notifyUUID) {
try {
const notification = await service.getCharacteristic(candidate.notifyUUID);
await notification.startNotifications?.();
} catch {
// Notifications are useful but not needed for printing.
}
}
this.profile = candidate;
this.controlCharacteristic = control;
this.dataCharacteristic = data;
return candidate;
} catch {
// Try the next known service/profile.
}
}
}
this.disconnect();
throw new Error("The selected device is not a supported PT210 or MX/MXW01 printer.");
}
public disconnect(): void {
this.device?.removeEventListener?.("gattserverdisconnected", this.onDisconnected);
if (this.device?.gatt?.connected) this.device.gatt.disconnect();
this.clearConnection();
}
/**
* Prints an MSB-first, one-bit raster. width must match the active profile.
*/
public async printMonochromeRaster(
raster: Uint8Array,
width: number,
height: number,
options: PrintOptions = {},
): Promise<void> {
const profile = this.requireConnection();
this.validateRaster(raster, width, height, profile);
if (profile.id === "pt210") {
await this.printPT210(raster, height);
return;
}
await this.printMXW01(raster, height, options);
}
/** Advances paper with a small blank raster. */
public async feed(lines = 16): Promise<void> {
const profile = this.requireConnection();
if (!Number.isInteger(lines) || lines < 1 || lines > 0xffff) {
throw new Error("lines must be an integer between 1 and 65535.");
}
const blankRaster = new Uint8Array((profile.printWidth / 8) * lines);
if (profile.id === "pt210") {
await this.printPT210(blankRaster, lines);
} else {
await this.printMXW01(blankRaster, lines, { feedLines: 0 });
}
}
private requireConnection(): PrinterProfile {
if (!this.connected || !this.profile) {
throw new Error("Printer is not connected. Call connect() from a user gesture first.");
}
return this.profile;
}
private validateRaster(raster: Uint8Array, width: number, height: number, profile: PrinterProfile): void {
if (width !== profile.printWidth || width % 8 !== 0) {
throw new Error(`${profile.name} requires a ${profile.printWidth}-pixel-wide raster.`);
}
if (!Number.isInteger(height) || height < 1 || height > 0xffff) {
throw new Error("height must be an integer between 1 and 65535.");
}
const expectedLength = (width / 8) * height;
if (raster.byteLength !== expectedLength) {
throw new Error(`Invalid raster length: expected ${expectedLength} bytes, received ${raster.byteLength}.`);
}
}
private async printPT210(raster: Uint8Array, height: number): Promise<void> {
const bytesPerRow = PT210_PROFILE.printWidth / 8;
const header = new Uint8Array([
0x1d, 0x76, 0x30, 0x00,
bytesPerRow, 0x00,
height & 0xff, (height >> 8) & 0xff,
]);
await this.write(this.controlCharacteristic, new Uint8Array([0x1b, 0x40]), true); // ESC @
await this.write(this.controlCharacteristic, header, true); // GS v 0
for (let offset = 0; offset < raster.length; offset += 128) {
await this.write(this.dataCharacteristic, raster.slice(offset, offset + 128), true);
await sleep(2);
}
await this.write(this.controlCharacteristic, new Uint8Array([0x0a]), true);
await this.write(this.controlCharacteristic, new Uint8Array([0x0a]), true);
}
private async printMXW01(raster: Uint8Array, height: number, options: PrintOptions): Promise<void> {
const bytesPerRow = MXW01_PROFILE.printWidth / 8;
const feedLines = options.feedLines ?? 10;
if (!Number.isInteger(feedLines) || feedLines < 0 || feedLines > 0xffff - height) {
throw new Error("feedLines must keep the total height between 1 and 65535.");
}
const minimumRows = 90;
const totalRows = Math.max(minimumRows, height + feedLines);
const output = new Uint8Array(totalRows * bytesPerRow);
output.set(raster);
const intensity = options.density === "faded" ? 0x30 : options.density === "dark" ? 0x80 : 0x5d;
await this.write(this.controlCharacteristic, this.makeMXCommand(0xa2, [intensity]), true);
await sleep(50);
await this.write(this.controlCharacteristic, this.makeMXCommand(0xa1, [0x00]), true);
await sleep(50);
await this.write(this.controlCharacteristic, this.makeMXCommand(0xa9, [
totalRows & 0xff, (totalRows >> 8) & 0xff, 0x30, 0x00,
]), true);
await sleep(100);
for (let offset = 0; offset < output.length; offset += bytesPerRow) {
await this.write(this.dataCharacteristic, output.slice(offset, offset + bytesPerRow), true);
await sleep(15);
}
await this.write(this.controlCharacteristic, this.makeMXCommand(0xad, [0x00]), true);
}
private makeMXCommand(command: number, payload: readonly number[]): Uint8Array {
const bytes = new Uint8Array(payload);
const packet = new Uint8Array(8 + bytes.length);
packet.set([0x22, 0x21, command, 0x00, bytes.length & 0xff, (bytes.length >> 8) & 0xff]);
packet.set(bytes, 6);
packet[6 + bytes.length] = this.crc8(bytes);
packet[7 + bytes.length] = 0xff;
return packet;
}
private crc8(data: Uint8Array): number {
let crc = 0;
for (const byte of data) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) {
crc = (crc & 0x80) ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
}
}
return crc;
}
private async write(characteristic: any, bytes: Uint8Array, withResponse: boolean): Promise<void> {
if (!characteristic) throw new Error("Printer GATT characteristic is unavailable.");
if (withResponse && typeof characteristic.writeValueWithResponse === "function") {
await characteristic.writeValueWithResponse(bytes);
} else if (withResponse && typeof characteristic.writeValue === "function") {
await characteristic.writeValue(bytes);
} else if (typeof characteristic.writeValueWithoutResponse === "function") {
await characteristic.writeValueWithoutResponse(bytes);
} else if (typeof characteristic.writeValue === "function") {
await characteristic.writeValue(bytes);
} else {
throw new Error("This GATT characteristic is not writable.");
}
}
private clearConnection(): void {
this.server = null;
this.controlCharacteristic = null;
this.dataCharacteristic = null;
this.profile = null;
this.device = null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment