From 48408bbde160953a2775bb71c58bc5c3c5c44dab Mon Sep 17 00:00:00 2001 From: Thomas Leiter Date: Mon, 4 May 2026 23:26:01 +0200 Subject: [PATCH] Add Ablemark M60 / Marklife X2 printer support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New X2 protocol (protocol 8) with zlib-compressed bitmap encoding, reverse-engineered from the Marklife APK and confirmed via Ghidra analysis of libCode.so (DFunction.code() = standard zlib compress). - X2 protocol: compressed bitmap (1F 10), density mapping 1→3/2→8/3→14 - M60 device profile: BLE name prefixes "M60"/"X2", 1ms packet delay - Added fflate dependency for cross-platform zlib compression --- bun.lock | 22 +++ package.json | 5 +- packages/core/package.json | 3 + packages/core/src/device/profiles/m60.ts | 42 ++++++ packages/core/src/device/registry.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/protocol/registry.ts | 2 + packages/core/src/protocol/x2/commands.ts | 159 ++++++++++++++++++++++ packages/core/src/protocol/x2/protocol.ts | 123 +++++++++++++++++ 9 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/device/profiles/m60.ts create mode 100644 packages/core/src/protocol/x2/commands.ts create mode 100644 packages/core/src/protocol/x2/protocol.ts diff --git a/bun.lock b/bun.lock index 8ff715e..185e65d 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,9 @@ "packages/core": { "name": "@thermoprint/core", "version": "0.1.0", + "dependencies": { + "fflate": "^0.8.2", + }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.7.0", @@ -40,6 +43,8 @@ "name": "@thermoprint/web", "version": "0.0.0", "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.2.5", + "@fontsource/inter": "^5.2.5", "@thermoprint/core": "workspace:*", "jsbarcode": "^3.12.3", "konva": "^10.2.1", @@ -48,6 +53,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3", + "zundo": "^2.3.0", "zustand": "^5.0.11", }, "devDependencies": { @@ -127,6 +133,10 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + + "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -461,6 +471,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -777,6 +789,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zundo": ["zundo@2.3.0", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -811,6 +825,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@thermoprint/core/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/qrcode/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -875,6 +891,8 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "@thermoprint/core/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "@types/qrcode/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], @@ -897,12 +915,16 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@thermoprint/core/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@thermoprint/core/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/package.json b/package.json index 3e5a8b0..a9c5b85 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "name": "thermoprint", "private": true, - "workspaces": ["packages/*"] + "workspaces": [ + "packages/*" + ], + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/core/package.json b/packages/core/package.json index 8b22357..3244015 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,5 +17,8 @@ "devDependencies": { "typescript": "^5.7.0", "@types/bun": "latest" + }, + "dependencies": { + "fflate": "^0.8.2" } } diff --git a/packages/core/src/device/profiles/m60.ts b/packages/core/src/device/profiles/m60.ts new file mode 100644 index 0000000..0cdbf02 --- /dev/null +++ b/packages/core/src/device/profiles/m60.ts @@ -0,0 +1,42 @@ +import type { DeviceProfile, LabelSizePreset } from "../types.js"; + +const gapSizes: LabelSizePreset[] = [ + { widthMm: 20, heightMm: 10 }, + { widthMm: 30, heightMm: 15 }, + { widthMm: 40, heightMm: 12 }, + { widthMm: 40, heightMm: 20 }, + { widthMm: 40, heightMm: 30 }, + { widthMm: 50, heightMm: 30 }, + { widthMm: 50, heightMm: 40 }, +]; + +const continuousSizes: LabelSizePreset[] = [ + { widthMm: 30, heightMm: 15 }, + { widthMm: 40, heightMm: 20 }, + { widthMm: 40, heightMm: 30 }, + { widthMm: 50, heightMm: 30 }, + { widthMm: 50, heightMm: 40 }, +]; + +export const m60Profile: DeviceProfile = { + modelId: "m60", + protocolId: "x2", + 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", + }, + flowControl: { + packetDelayMs: 1, + }, + defaults: { density: 2, paperType: "gap" }, + namePrefixes: ["M60", "X2"], + labelConfig: { + supportedPaperTypes: ["gap", "continuous"], + defaultPaperType: "gap", + gapSizes, + continuousSizes, + defaultSize: { widthMm: 50, heightMm: 30 }, + }, +}; diff --git a/packages/core/src/device/registry.ts b/packages/core/src/device/registry.ts index a6f61cb..ae7601e 100644 --- a/packages/core/src/device/registry.ts +++ b/packages/core/src/device/registry.ts @@ -1,6 +1,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"; const devices: DeviceProfile[] = []; @@ -30,3 +31,4 @@ export function getRegisteredDevices(): DeviceProfile[] { // Register built-in devices registerDevice(p15Profile); registerDevice(p12Profile); +registerDevice(m60Profile); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 546d980..49bcea1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,7 @@ export type { } from "./protocol/types.js"; export { registerProtocol, getProtocol } from "./protocol/registry.js"; export { L11Protocol } from "./protocol/l11/protocol.js"; +export { X2Protocol } from "./protocol/x2/protocol.js"; // Device types & registry (for adding new printer models) export type { diff --git a/packages/core/src/protocol/registry.ts b/packages/core/src/protocol/registry.ts index 20ba9da..591a320 100644 --- a/packages/core/src/protocol/registry.ts +++ b/packages/core/src/protocol/registry.ts @@ -1,5 +1,6 @@ import type { PrinterProtocol } from "./types.js"; import { L11Protocol } from "./l11/protocol.js"; +import { X2Protocol } from "./x2/protocol.js"; import { ThermoprintError, ErrorCode } from "../errors.js"; type ProtocolFactory = () => PrinterProtocol; @@ -27,3 +28,4 @@ export function getRegisteredProtocolIds(): string[] { // Register built-in protocols registerProtocol("l11", () => new L11Protocol()); +registerProtocol("x2", () => new X2Protocol()); diff --git a/packages/core/src/protocol/x2/commands.ts b/packages/core/src/protocol/x2/commands.ts new file mode 100644 index 0000000..2f337ce --- /dev/null +++ b/packages/core/src/protocol/x2/commands.ts @@ -0,0 +1,159 @@ +import { zlibSync } from "fflate"; +import type { ImageBitmap1bpp, PrintCommand } from "../types.js"; + +/** 6 zero bytes to wake the printer */ +export function wakeup(): PrintCommand { + return { label: "wakeup", data: new Uint8Array(6) }; +} + +/** Start print job: 1F C0 01 00 */ +export function enable(): PrintCommand { + return { label: "enable", data: Uint8Array.from([0x1f, 0xc0, 0x01, 0x00]) }; +} + +/** End print job: 1F C0 01 01 */ +export function stop(): PrintCommand { + return { label: "stop", data: Uint8Array.from([0x1f, 0xc0, 0x01, 0x01]) }; +} + +/** + * Set density: 1F 70 02 + * Density mapping: 1→3, 2→8, 3→14 + */ +export function setDensity(density: number): PrintCommand { + const DENSITY_MAP: Record = { 1: 3, 2: 8, 3: 14 }; + const value = DENSITY_MAP[density] ?? 8; + return { + label: "set-density", + data: Uint8Array.from([0x1f, 0x70, 0x02, value & 0xff]), + }; +} + +/** Set paper type to gap: 1F 80 02 20 */ +export function setPaperTypeGap(): PrintCommand { + return { + label: "set-paper-type", + data: Uint8Array.from([0x1f, 0x80, 0x02, 0x20]), + }; +} + +/** Feed n dots: 1B 4A 00 */ +export function feedDots(dots: number): PrintCommand { + return { + label: "feed-dots", + data: Uint8Array.from([0x1b, 0x4a, dots & 0xff, (dots >> 8) & 0xff, 0x00]), + }; +} + +/** Adjust position auto: 1F 11 */ +export function adjustPositionAuto(param: number): PrintCommand { + return { + label: "adjust-position", + data: Uint8Array.from([0x1f, 0x11, param & 0xff]), + }; +} + +/** Printer location: 1F 12 */ +export function printerLocation(x: number, y: number): PrintCommand { + return { + label: "printer-location", + data: Uint8Array.from([0x1f, 0x12, x & 0xff, y & 0xff]), + }; +} + +/** + * Build a compressed bitmap command: 1F 10 + zlib data + * Compression: standard zlib compress() with default parameters (level 6). + */ +export function printBitmap(image: ImageBitmap1bpp): PrintCommand { + const { data: pixels, bytesPerRow, height } = image; + const compressed = zlibSync(pixels, { level: 6 }); + + const header = Uint8Array.from([ + 0x1f, + 0x10, + (bytesPerRow >> 8) & 0xff, + bytesPerRow & 0xff, + (height >> 8) & 0xff, + height & 0xff, + (compressed.length >> 24) & 0xff, + (compressed.length >> 16) & 0xff, + (compressed.length >> 8) & 0xff, + compressed.length & 0xff, + ]); + + const command = new Uint8Array(header.length + compressed.length); + command.set(header, 0); + command.set(compressed, header.length); + + return { label: "print-bitmap", data: command, bulk: true }; +} + +/** Query battery level: 10 FF 50 F1 */ +export function getBattery(): PrintCommand { + return { + label: "get-battery", + data: Uint8Array.from([0x10, 0xff, 0x50, 0xf1]), + }; +} + +/** Query printer status: 10 FF 40 */ +export function getStatus(): PrintCommand { + return { label: "get-status", data: Uint8Array.from([0x10, 0xff, 0x40]) }; +} + +/** Query model string: 10 FF 20 F0 */ +export function getModel(): PrintCommand { + return { + label: "get-model", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf0]), + }; +} + +/** Query firmware version: 10 FF 20 F1 */ +export function getFirmware(): PrintCommand { + return { + label: "get-firmware", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf1]), + }; +} + +/** Query serial number: 10 FF 20 F2 */ +export function getSerial(): PrintCommand { + return { + label: "get-serial", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf2]), + }; +} + +/** Query Bluetooth MAC address: 10 FF 20 F3 */ +export function getMac(): PrintCommand { + return { + label: "get-mac", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf3]), + }; +} + +/** Query BT module version: 10 FF 30 10 */ +export function getBtVersion(): PrintCommand { + return { + label: "get-bt-version", + data: Uint8Array.from([0x10, 0xff, 0x30, 0x10]), + }; +} + +/** Query BT device name: 10 FF 30 11 */ +export function getBtName(): PrintCommand { + return { + label: "get-bt-name", + data: Uint8Array.from([0x10, 0xff, 0x30, 0x11]), + }; +} + +/** Query print speed: 1F 60 00 */ +export function getSpeed(): PrintCommand { + return { + label: "get-speed", + data: Uint8Array.from([0x1f, 0x60, 0x00]), + }; +} diff --git a/packages/core/src/protocol/x2/protocol.ts b/packages/core/src/protocol/x2/protocol.ts new file mode 100644 index 0000000..261e212 --- /dev/null +++ b/packages/core/src/protocol/x2/protocol.ts @@ -0,0 +1,123 @@ +import type { + ImageBitmap1bpp, + PrintCommand, + PrinterProtocol, + PrinterResponse, + PrintSequenceOptions, +} from "../types.js"; +import * as cmd from "./commands.js"; + +const STATUS_CODES: Record = { + 0x01: "out_of_paper", + 0x02: "cover_open", + 0x03: "overheating", + 0x04: "low_battery", + 0x05: "cover_closed", +}; + +export class X2Protocol implements PrinterProtocol { + readonly id = "x2"; + + buildPrintSequence( + image: ImageBitmap1bpp, + options: PrintSequenceOptions = {}, + ): PrintCommand[] { + const { density, paperType = "gap" } = options; + const commands: PrintCommand[] = []; + + if (paperType === "gap") { + commands.push(cmd.setPaperTypeGap()); + } + + if (density !== undefined) { + commands.push(cmd.setDensity(density)); + } + + commands.push(cmd.wakeup()); + commands.push(cmd.enable()); + + if (paperType === "gap") { + commands.push(cmd.adjustPositionAuto(0x51)); + } else { + commands.push(cmd.feedDots(100)); + } + + commands.push(cmd.printBitmap(image)); + + if (paperType === "gap") { + commands.push(cmd.printerLocation(0x20, 0x00)); + } else { + commands.push(cmd.printerLocation(0x00, 0x00)); + } + + commands.push(cmd.stop()); + + if (paperType === "gap") { + commands.push(cmd.adjustPositionAuto(0x50)); + } else { + commands.push(cmd.adjustPositionAuto(0x00)); + } + + return commands; + } + + buildWakeup(): PrintCommand[] { + return [cmd.wakeup()]; + } + + buildStatusQuery(): PrintCommand { + return cmd.getStatus(); + } + + buildBatteryQuery(): PrintCommand { + return cmd.getBattery(); + } + + buildModelQuery(): PrintCommand { + return cmd.getModel(); + } + + buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand { + switch (type) { + case "firmware": return cmd.getFirmware(); + case "serial": return cmd.getSerial(); + case "mac": return cmd.getMac(); + case "bt-version": return cmd.getBtVersion(); + case "bt-name": return cmd.getBtName(); + case "speed": return cmd.getSpeed(); + } + } + + parseResponse(data: Uint8Array): PrinterResponse | null { + if (data.length < 1) return null; + + const first = data[0]; + + if (first === 0xaa || first === 0x4f || first === 0x4b) { + return { type: "success", raw: data }; + } + + if (data.length < 2) return null; + const second = data[1]; + + if (first === 0x01) { + return { type: "credit", raw: data, value: second }; + } + + if (first === 0x02 && data.length >= 3) { + const mtu = (data[2] << 8) | data[1]; + return { type: "mtu", raw: data, value: mtu }; + } + + if (first === 0xff) { + const status = STATUS_CODES[second]; + return { + type: "status", + raw: data, + value: status ?? `unknown_${second.toString(16)}`, + }; + } + + return null; + } +}