|
/** |
|
* 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; |
|
} |
|
} |