diff --git a/.github/workflows/publish-npmjs.yml b/.github/workflows/publish-npmjs.yml new file mode 100644 index 0000000..a1bb4a7 --- /dev/null +++ b/.github/workflows/publish-npmjs.yml @@ -0,0 +1,33 @@ +name: Publish Package to npmjs +on: + push: + branches: + - npm-test + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + scope: '@mmote' + + - name: Install dependencies + run: yarn install + + - name: Build + run: yarn build + + - name: Publish + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64f9aa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.vscode +/dist +/node_modules +/yarn-error.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf4f6db --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +## NiimBlueLib [](https://npmjs.com/package/@mmote/niimbluelib) + +> [!WARNING] +> +> This project is intended for informational and educational purposes only. +> The project is not affiliated with or endorsed by the original software or hardware vendor, +> and is not intended to be used for commercial purposes without the consent of the vendor. + +NiimBlueLib is a library for the communication with NIIMBOT printers. + +Project is in Alpha state. Use only exact version when you add it to your project. API can be changed anytime. + +### Usage example + +```js +import { Utils, RequestCommandId, ResponseCommandId, NiimbotBluetoothClient, ImageEncoder } from "@mmote/niimbluelib"; + +const client = new NiimbotBluetoothClient(); + +client.addEventListener("packetsent", (e) => { + console.log(`>> ${Utils.bufToHex(e.packet.toBytes())} (${RequestCommandId[e.packet.command]})`); +}); + +client.addEventListener("packetreceived", (e) => { + console.log(`<< ${Utils.bufToHex(e.packet.toBytes())} (${ResponseCommandId[e.packet.command]})`); +}); + +client.addEventListener("connect", () => { + console.log("connected"); +}); + +client.addEventListener("disconnect", () => { + console.log("disconnected"); +}); + +client.addEventListener("printprogress", (e) => { + console.log(`Page ${e.page}/${e.pagesTotal}, Page print ${e.pagePrintProgress}%, Page feed ${e.pageFeedProgress}%`); +}); + +await client.connect(); + +// label props +const props = { + width: 240, + height: 96, + printDirection: "left", +}; +const quantity = 1; + +const canvas = document.createElement("canvas"); +canvas.width = props.width; +canvas.height = props.height; + +const ctx = canvas.getContext("2d"); + +ctx.fillStyle = "white"; +ctx.lineWidth = 3; + +// fill background +ctx.fillRect(0, 0, canvas.width, canvas.height); +// draw diagonal line +ctx.beginPath(); +ctx.moveTo(0, 0); +ctx.lineTo(canvas.width, canvas.height); +ctx.stroke(); +// draw border +ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1); + +const image = ImageEncoder.encodeCanvas(canvas, props.printDirection); + +await client.abstraction.print(client.getPrintTaskVersion(), image, { quantity }); + +try { + await client.abstraction.waitUntilPrintFinished(quantity); +} catch (e) { + console.error(e); +} + +await client.abstraction.printEnd(); +await client.disconnect(); +``` + +### Misc + +Eslint not included. Install it with: + +``` +npm install --no-save --no-package-lock eslint@9.x globals @eslint/js typescript-eslint +``` \ No newline at end of file diff --git a/clean-dist.mjs b/clean-dist.mjs new file mode 100644 index 0000000..07a2806 --- /dev/null +++ b/clean-dist.mjs @@ -0,0 +1,3 @@ +import fs from "fs"; + +fs.rmSync("dist", { recursive: true, force: true }); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2ee9ae4 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + { files: ["**/*.{ts}"] }, + { + languageOptions: { + globals: globals.browser, + parserOptions: { + project: "tsconfig.json", + }, + }, + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { ignores: ["dist/*", "dumps/*", "**/*.{mjs,js}"] }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5744687 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@mmote/niimbluelib", + "version": "0.0.1", + "description": "Library for the communication with NIIMBOT printers", + "keywords": [ + "reverse-engineering", + "thermal-printer", + "label-printer", + "niimbot", + "niimbot-d110", + "niimbot-b1", + "bluetooth", + "serial" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "/dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/MultiMote/niimbluelib.git" + }, + "author": "MultiMote", + "license": "MIT", + "private": false, + "scripts": { + "clean-build": "yarn clean && yarn build", + "build": "tsc --build", + "parse-dump": "yarn build && node utils/parse-dump.mjs", + "clean": "node clean-dist.mjs", + "gen-printer-models": "node utils/gen-printer-models.js > src/printer_models.ts" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "@types/w3c-web-serial": "^1.0.6", + "@types/web-bluetooth": "^0.0.20", + "typescript": "^5.4.5" + }, + "dependencies": { + "async-mutex": "^0.5.0", + "typescript-event-target": "^1.1.1" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} \ No newline at end of file diff --git a/src/client/bluetooth_impl.ts b/src/client/bluetooth_impl.ts new file mode 100644 index 0000000..c082969 --- /dev/null +++ b/src/client/bluetooth_impl.ts @@ -0,0 +1,159 @@ +import { Mutex } from "async-mutex"; +import { ConnectionInfo, NiimbotAbstractClient, ConnectResult, NiimbotPacket, ResponseCommandId, Utils } from ".."; +import { + ConnectEvent, + DisconnectEvent, + PacketReceivedEvent, + RawPacketReceivedEvent, + RawPacketSentEvent, +} from "./events"; + +class BleConfiguration { + public static readonly SERVICE: string = "e7810a71-73ae-499d-8c15-faa9aef0c3f2"; + public static readonly CHARACTERISTIC: string = "bef8d6c9-9c21-4c9e-b632-bd58c1009f9f"; + public static readonly FILTER: BluetoothLEScanFilter[] = [ + { namePrefix: "D" }, + { namePrefix: "B" }, + { services: [BleConfiguration.SERVICE] }, + ]; +} + +/** Uses Web Bluetooth API */ +export class NiimbotBluetoothClient extends NiimbotAbstractClient { + private gattServer?: BluetoothRemoteGATTServer = undefined; + private channel?: BluetoothRemoteGATTCharacteristic = undefined; + private mutex: Mutex = new Mutex(); + + public async connect(): Promise<ConnectionInfo> { + await this.disconnect(); + + const options: RequestDeviceOptions = { + filters: BleConfiguration.FILTER, + }; + const device: BluetoothDevice = await navigator.bluetooth.requestDevice(options); + + if (device.gatt === undefined) { + throw new Error("Device has no Bluetooth Generic Attribute Profile"); + } + + const disconnectListener = () => { + this.gattServer = undefined; + this.channel = undefined; + this.info = {}; + this.stopHeartbeat(); + this.dispatchTypedEvent("disconnect", new DisconnectEvent()); + device.removeEventListener("gattserverdisconnected", disconnectListener); + }; + + device.addEventListener("gattserverdisconnected", disconnectListener); + + const gattServer: BluetoothRemoteGATTServer = await device.gatt.connect(); + + const service: BluetoothRemoteGATTService = await gattServer.getPrimaryService(BleConfiguration.SERVICE); + + const channel: BluetoothRemoteGATTCharacteristic = await service.getCharacteristic(BleConfiguration.CHARACTERISTIC); + + channel.addEventListener("characteristicvaluechanged", (event: Event) => { + const target = event.target as BluetoothRemoteGATTCharacteristic; + const data = new Uint8Array(target.value!.buffer); + const packet = NiimbotPacket.fromBytes(data); + + this.dispatchTypedEvent("rawpacketreceived", new RawPacketReceivedEvent(data)); + this.dispatchTypedEvent("packetreceived", new PacketReceivedEvent(packet)); + + if (!(packet.command in ResponseCommandId)) { + console.warn(`Unknown response command: 0x${Utils.numberToHex(packet.command)}`); + } + }); + + await channel.startNotifications(); + + this.gattServer = gattServer; + this.channel = channel; + + try { + await this.initialNegotiate(); + await this.fetchPrinterInfo(); + } catch (e) { + console.error("Unable to fetch printer info."); + console.error(e); + } + + const result: ConnectionInfo = { + deviceName: device.name, + result: this.info.connectResult ?? ConnectResult.FirmwareErrors, + }; + + this.dispatchTypedEvent("connect", new ConnectEvent(result)); + + return result; + } + + public isConnected(): boolean { + return this.gattServer !== undefined && this.channel !== undefined; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect() { + this.stopHeartbeat(); + this.gattServer?.disconnect(); + this.gattServer = undefined; + this.channel = undefined; + this.info = {}; + } + + /** + * Send packet and wait for response. + * If packet.responsePacketCommandId is defined, it will wait for packet with this command id. + */ + public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs?: number): Promise<NiimbotPacket> { + return this.mutex.runExclusive(async () => { + await this.sendPacket(packet, true); + + if (packet.oneWay) { + return new NiimbotPacket(ResponseCommandId.Invalid, []); // or undefined is better? + } + + // what if response received at this point? + + return new Promise((resolve, reject) => { + let timeout: NodeJS.Timeout | undefined = undefined; + + const listener = (evt: PacketReceivedEvent) => { + if ( + packet.validResponseIds.length === 0 || + packet.validResponseIds.includes(evt.packet.command as ResponseCommandId) + ) { + clearTimeout(timeout); + this.removeEventListener("packetreceived", listener); + resolve(evt.packet); + } + }; + + timeout = setTimeout(() => { + this.removeEventListener("packetreceived", listener); + reject(new Error(`Timeout waiting response (waited for ${Utils.bufToHex(packet.validResponseIds, ", ")})`)); + }, timeoutMs ?? 1000); + + this.addEventListener("packetreceived", listener); + }); + }); + } + + public async sendRaw(data: Uint8Array, force?: boolean) { + const send = async () => { + if (this.channel === undefined) { + throw new Error("Channel is closed"); + } + await Utils.sleep(this.packetIntervalMs); + await this.channel.writeValueWithoutResponse(data); + this.dispatchTypedEvent("rawpacketsent", new RawPacketSentEvent(data)); + }; + + if (force) { + await send(); + } else { + await this.mutex.runExclusive(send); + } + } +} diff --git a/src/client/events.ts b/src/client/events.ts new file mode 100644 index 0000000..bd376a7 --- /dev/null +++ b/src/client/events.ts @@ -0,0 +1,94 @@ +import { ConnectionInfo, PrinterInfo, HeartbeatData, NiimbotPacket } from ".."; + +export class ConnectEvent extends Event { + info: ConnectionInfo; + constructor(info: ConnectionInfo) { + super("connect"); + this.info = info; + } +} + +export class DisconnectEvent extends Event { + constructor() { + super("disconnect"); + } +} + +export class PacketReceivedEvent extends Event { + packet: NiimbotPacket; + constructor(packet: NiimbotPacket) { + super("packetreceived"); + this.packet = packet; + } +} + +export class PacketSentEvent extends Event { + packet: NiimbotPacket; + constructor(packet: NiimbotPacket) { + super("packetsent"); + this.packet = packet; + } +} + +export class RawPacketSentEvent extends Event { + data: Uint8Array; + constructor(data: Uint8Array) { + super("rawpacketsent"); + this.data = data; + } +} + +export class RawPacketReceivedEvent extends Event { + data: Uint8Array; + constructor(data: Uint8Array) { + super("rawpacketreceived"); + this.data = data; + } +} + +export class HeartbeatEvent extends Event { + data: HeartbeatData; + constructor(data: HeartbeatData) { + super("heartbeat"); + this.data = data; + } +} + +export class PrinterInfoFetchedEvent extends Event { + info: PrinterInfo; + constructor(info: PrinterInfo) { + super("printerinfofetched"); + this.info = info; + } +} + +export class PrintProgressEvent extends Event { + /** 0 – n */ + page: number; + + pagesTotal: number; + /** 0 – 100 */ + pagePrintProgress: number; + /** 0 – 100 */ + pageFeedProgress: number; + + constructor(page: number, pagesTotal: number, pagePrintProgress: number, pageFeedProgress: number) { + super("printprogress"); + this.page = page; + this.pagesTotal = pagesTotal; + this.pagePrintProgress = pagePrintProgress; + this.pageFeedProgress = pageFeedProgress; + } +} + +export interface ClientEventMap { + connect: ConnectEvent; + disconnect: DisconnectEvent; + rawpacketsent: RawPacketSentEvent; + rawpacketreceived: RawPacketReceivedEvent; + packetreceived: PacketReceivedEvent; + packetsent: PacketSentEvent; + heartbeat: HeartbeatEvent; + printerinfofetched: PrinterInfoFetchedEvent; + printprogress: PrintProgressEvent; +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..3e6d010 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,155 @@ +import { TypedEventTarget } from "typescript-event-target"; +import { + AutoShutdownTime, + BatteryChargeLevel, + ConnectResult, + getPrintTaskVersion, + LabelType, + NiimbotPacket, + PrintTaskVersion, + ClientEventMap, + HeartbeatEvent, + PacketSentEvent, + PrinterInfoFetchedEvent, + Abstraction, + getPrinterMetaById, + PrinterModelMeta, +} from ".."; + +export type ConnectionInfo = { + deviceName?: string; + result: ConnectResult; +}; + +export interface PrinterInfo { + connectResult?: ConnectResult; + protocolVersion?: number; + model_id?: number; + serial?: string; + mac?: string; + charge?: BatteryChargeLevel; + autoShutdownTime?: AutoShutdownTime; + labelType?: LabelType; +} + +export abstract class NiimbotAbstractClient extends TypedEventTarget<ClientEventMap> { + public readonly abstraction: Abstraction; + protected info: PrinterInfo = {}; + private heartbeatTimer?: NodeJS.Timeout; + /** https://github.com/MultiMote/niimblue/issues/5 */ + protected packetIntervalMs: number = 10; + + constructor() { + super(); + this.abstraction = new Abstraction(this); + } + + /** Connect to printer port */ + public abstract connect(): Promise<ConnectionInfo>; + + /** Disconnect from printer port */ + public abstract disconnect(): Promise<void>; + + public abstract isConnected(): boolean; + + /** + * Send packet and wait for response. + * If packet.responsePacketCommandId is defined, it will wait for packet with this command id. + */ + public abstract sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs?: number): Promise<NiimbotPacket>; + + /** + * Send raw bytes to the printer port. + * + * @param data Bytes to send. + * @param force Ignore mutex lock. You should avoid using it. + */ + public abstract sendRaw(data: Uint8Array, force?: boolean): Promise<void>; + + public async sendPacket(packet: NiimbotPacket, force?: boolean) { + await this.sendRaw(packet.toBytes(), force); + this.dispatchTypedEvent("packetsent", new PacketSentEvent(packet)); + } + + /** Send "connect" packet and fetch the protocol version */ + protected async initialNegotiate(): Promise<void> { + const cfg = this.info; + cfg.connectResult = await this.abstraction.connectResult(); + cfg.protocolVersion = 0; + + if (cfg.connectResult === ConnectResult.ConnectedNew) { + cfg.protocolVersion = 1; + } else if (cfg.connectResult === ConnectResult.ConnectedV3) { + const statusData = await this.abstraction.getPrinterStatusData(); + cfg.protocolVersion = statusData.protocolVersion; + } + } + + public async fetchPrinterInfo(): Promise<PrinterInfo> { + // console.log(await this.abstraction.getPrinterStatusData()); + this.info.model_id = await this.abstraction.getPrinterModel(); + this.info.serial = await this.abstraction.getPrinterSerialNumber(); + this.info.mac = await this.abstraction.getPrinterBluetoothMacAddress(); + this.info.charge = await this.abstraction.getBatteryChargeLevel(); + this.info.autoShutdownTime = await this.abstraction.getAutoShutDownTime(); + this.info.labelType = await this.abstraction.getLabelType(); + this.dispatchTypedEvent("printerinfofetched", new PrinterInfoFetchedEvent(this.info)); + return this.info; + } + + public getPrinterInfo(): PrinterInfo { + return this.info; + } + + /** + * Starts the heartbeat timer, "heartbeat" is emitted after packet received. + * + * @param interval Heartbeat interval, default is 1000ms + */ + public startHeartbeat(intervalMs: number = 1000): void { + this.heartbeatTimer = setInterval(() => { + this.abstraction + .heartbeat() + .then((data) => { + this.dispatchTypedEvent("heartbeat", new HeartbeatEvent(data)); + }) + .catch(console.error); + }, intervalMs); + } + + public stopHeartbeat(): void { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + + public isHeartbeatStarted(): boolean { + return this.heartbeatTimer === undefined; + } + + /** Get printer capabilities based on the printer model. Model library is hardcoded. */ + public getModelMetadata(): PrinterModelMeta | undefined { + if (this.info.model_id === undefined) { + return undefined; + } + return getPrinterMetaById(this.info.model_id); + } + + /** Determine print task version if any */ + public getPrintTaskVersion(): PrintTaskVersion | undefined { + const meta = this.getModelMetadata(); + + if (meta === undefined) { + return undefined; + } + + return getPrintTaskVersion(meta.model); + } + + public setPacketInterval(milliseconds: number) { + this.packetIntervalMs = milliseconds; + } +} + +export * from "./events"; +export * from "./bluetooth_impl"; +export * from "./serial_impl"; diff --git a/src/client/serial_impl.ts b/src/client/serial_impl.ts new file mode 100644 index 0000000..8845ead --- /dev/null +++ b/src/client/serial_impl.ts @@ -0,0 +1,184 @@ +import { Mutex } from "async-mutex"; +import { ConnectionInfo, NiimbotAbstractClient, ConnectResult, NiimbotPacket, ResponseCommandId, Utils } from ".."; +import { + ConnectEvent, + DisconnectEvent, + PacketReceivedEvent, + RawPacketReceivedEvent, + RawPacketSentEvent, +} from "./events"; + +/** Uses Web Serial API */ +export class NiimbotSerialClient extends NiimbotAbstractClient { + private port?: SerialPort = undefined; + private writer?: WritableStreamDefaultWriter<Uint8Array> = undefined; + private reader?: ReadableStreamDefaultReader<Uint8Array> = undefined; + private mutex: Mutex = new Mutex(); + + public async connect(): Promise<ConnectionInfo> { + await this.disconnect(); + + const _port: SerialPort = await navigator.serial.requestPort(); + + _port.addEventListener("disconnect", () => { + this.port = undefined; + console.log("serial disconnect event"); + this.dispatchTypedEvent("disconnect", new DisconnectEvent()); + }); + + await _port.open({ baudRate: 115200 }); + + if (_port.readable === null) { + throw new Error("Port is not readable"); + } + + if (_port.writable === null) { + throw new Error("Port is not writable"); + } + + this.port = _port; + const info = _port.getInfo(); + this.writer = _port.writable.getWriter(); + this.reader = _port.readable.getReader(); + + setTimeout(() => { + void (async () => { + await this.waitSerialData(); + })(); + }, 1); // todo: maybe some other way exists + + try { + await this.initialNegotiate(); + await this.fetchPrinterInfo(); + } catch (e) { + console.error("Unable to fetch printer info (is it turned on?)."); + console.error(e); + } + + const result: ConnectionInfo = { + deviceName: `Serial (VID:${info.usbVendorId?.toString(16)} PID:${info.usbProductId?.toString(16)})`, + result: this.info.connectResult ?? ConnectResult.FirmwareErrors, + }; + + this.dispatchTypedEvent("connect", new ConnectEvent(result)); + return result; + } + + private async waitSerialData() { + let buf = new Uint8Array(); + + while (true) { + try { + const result = await this.reader!.read(); + if (result.value) { + // console.info(`<< serial chunk ${Utils.bufToHex(result.value)}`); + + const newBuf = new Uint8Array(buf.length + result.value.length); + newBuf.set(buf, 0); + newBuf.set(result.value, buf.length); + buf = newBuf; + } + + if (result.done) { + console.log("done"); + break; + } + } catch (e) { + break; + } + + try { + const packets: NiimbotPacket[] = NiimbotPacket.fromBytesMultiPacket(buf); + + if (packets.length > 0) { + this.dispatchTypedEvent("rawpacketreceived", new RawPacketReceivedEvent(buf)); + + packets.forEach((p) => { + this.dispatchTypedEvent("packetreceived", new PacketReceivedEvent(p)); + }); + + buf = new Uint8Array(); + } + } catch (e) { + // console.info(`Incomplete packet, ignoring:${Utils.bufToHex(buf)}`); + } + } + } + + public async disconnect() { + this.stopHeartbeat(); + + if (this.writer !== undefined) { + this.writer.releaseLock(); + } + + if (this.reader !== undefined) { + this.reader.releaseLock(); + } + + if (this.port !== undefined) { + await this.port.close(); + this.dispatchTypedEvent("disconnect", new DisconnectEvent()); + } + + this.port = undefined; + this.writer = undefined; + } + + public isConnected(): boolean { + return this.port !== undefined && this.writer !== undefined; + } + + public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs: number = 1000): Promise<NiimbotPacket> { + if (!this.port?.readable || !this.port?.writable) { + throw new Error("Port is not readable/writable"); + } + + return this.mutex.runExclusive(async () => { + await this.sendPacket(packet, true); + + if (packet.oneWay) { + return new NiimbotPacket(ResponseCommandId.Invalid, []); // or undefined is better? + } + + return new Promise((resolve, reject) => { + let timeout: NodeJS.Timeout | undefined = undefined; + + const listener = (evt: PacketReceivedEvent) => { + if ( + packet.validResponseIds.length === 0 || + packet.validResponseIds.includes(evt.packet.command as ResponseCommandId) + ) { + clearTimeout(timeout); + this.removeEventListener("packetreceived", listener); + resolve(evt.packet); + } + }; + + timeout = setTimeout(() => { + this.removeEventListener("packetreceived", listener); + reject(new Error(`Timeout waiting response (waited for ${Utils.bufToHex(packet.validResponseIds, ", ")})`)); + }, timeoutMs ?? 1000); + + this.addEventListener("packetreceived", listener); + }); + }); + } + + public async sendRaw(data: Uint8Array, force?: boolean) { + const send = async () => { + if (this.writer === undefined) { + throw new Error("Port is not writable"); + } + await Utils.sleep(this.packetIntervalMs); + await this.writer.write(data); + this.dispatchTypedEvent("rawpacketsent", new RawPacketSentEvent(data)); + }; + + if (force) { + await send(); + } else { + await this.mutex.runExclusive(send); + } + } +} diff --git a/src/image_encoder.ts b/src/image_encoder.ts new file mode 100644 index 0000000..8a3c661 --- /dev/null +++ b/src/image_encoder.ts @@ -0,0 +1,116 @@ +import { Utils } from "."; + +export type ImageRow = { + dataType: "void" | "pixels"; + rowNumber: number; + repeat: number; + blackPixelsCount: number; + rowData?: Uint8Array; +}; + +export type EncodedImage = { + cols: number; + rows: number; + rowsData: ImageRow[]; +}; + +export type PrintDirection = "left" | "top"; + +export class ImageEncoder { + /** printDirection = "left" rotates image for 90 degrees clockwise */ + public static encodeCanvas(canvas: HTMLCanvasElement, printDirection: PrintDirection = "left"): EncodedImage { + const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!; + const iData: ImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const rowsData: ImageRow[] = []; + + let cols: number = canvas.width; + let rows: number = canvas.height; + + if (printDirection === "left") { + cols = canvas.height; + rows = canvas.width; + } + + if (cols % 8 !== 0) { + throw new Error("Column count must be multiple of 8"); + } + + for (let row = 0; row < rows; row++) { + let isVoid: boolean = true; + let blackPixelsCount: number = 0; + const rowData = new Uint8Array(cols / 8); + + for (let colOct = 0; colOct < cols / 8; colOct++) { + let pixelsOctet: number = 0; + for (let colBit = 0; colBit < 8; colBit++) { + if (ImageEncoder.isPixelNonWhite(iData, colOct * 8 + colBit, row, printDirection)) { + pixelsOctet |= 1 << (7 - colBit); + isVoid = false; + blackPixelsCount++; + } + } + rowData[colOct] = pixelsOctet; + } + + const newPart: ImageRow = { + dataType: isVoid ? "void" : "pixels", + rowNumber: row, + repeat: 1, + rowData: isVoid ? undefined : rowData, + blackPixelsCount, + }; + + // Check previous row and increment repeats instead of adding new row if data is same + if (rowsData.length === 0) { + rowsData.push(newPart); + } else { + const lastPacket: ImageRow = rowsData[rowsData.length - 1]; + let same: boolean = newPart.dataType === lastPacket.dataType; + + if (same && newPart.dataType === "pixels") { + same = Utils.u8ArraysEqual(newPart.rowData!, lastPacket.rowData!); + } + + if (same) { + lastPacket.repeat++; + } else { + rowsData.push(newPart); + } + } + } + + return { cols, rows, rowsData }; + } + + /** printDirection = "left" rotates image to 90 degrees clockwise */ + public static isPixelNonWhite(iData: ImageData, x: number, y: number, printDirection: PrintDirection = "left"): boolean { + let idx = y * iData.width + x; + + if (printDirection === "left") { + idx = (iData.height - 1 - x) * iData.width + y; + } + + idx *= 4; + return iData.data[idx] !== 255 || iData.data[idx + 1] !== 255 || iData.data[idx + 2] !== 255; + } + + /** + * @param data Pixels encoded by {@link encodeCanvas} (byte is 8 pixels) + * @returns Array of indexes where every index stored in two bytes (big endian) + */ + public static indexPixels(data: Uint8Array): Uint8Array { + const result: number[] = []; + + for (let bytePos = 0; bytePos < data.byteLength; bytePos++) { + const b: number = data[bytePos]; + for (let bitPos = 0; bitPos < 8; bitPos++) { + // iterate from most significant bit of byte + if (b & (1 << (7 - bitPos))) { + result.push(...Utils.u16ToBytes(bytePos * 8 + bitPos)); + } + } + } + + return new Uint8Array(result); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8263d63 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from "./client"; +export * from "./packets"; +export * from "./image_encoder"; +export * from "./utils"; +export * from "./printer_models"; diff --git a/src/packets/abstraction.ts b/src/packets/abstraction.ts new file mode 100644 index 0000000..b154a71 --- /dev/null +++ b/src/packets/abstraction.ts @@ -0,0 +1,342 @@ +import { + AutoShutdownTime, + BatteryChargeLevel, + ConnectResult, + HeartbeatType, + LabelType, + NiimbotPacket, + PacketGenerator, + PrinterInfoType, + PrintOptions, + ResponseCommandId, + SoundSettingsItemType, + SoundSettingsType, + EncodedImage, + NiimbotAbstractClient, + Utils, + Validators, + PrintTaskVersion, + PrintProgressEvent, + SequentialDataReader +} from ".."; + +export class PrintError extends Error { + public readonly reasonId: number; + + constructor(message: string, reasonId: number) { + super(message); + this.reasonId = reasonId; + } +} + +export interface PrintStatus { + /** 0 – n */ + page: number; + /** 0 – 100 */ + pagePrintProgress: number; + /** 0 – 100 */ + pageFeedProgress: number; +} + +export interface RfidInfo { + tagPresent: boolean; + uuid: string; + barCode: string; + serialNumber: string; + allPaper: number; + usedPaper: number; + consumablesType: LabelType; +} + +/** closingState inverted on some printers */ +export interface HeartbeatData { + paperState: number; + rfidReadState: number; + lidClosed: boolean; + powerLevel: BatteryChargeLevel; +} + +export interface SoundSettings { + category: SoundSettingsType; + item: SoundSettingsItemType; + value: boolean; +} + +export interface PrinterStatusData { + supportColor: number; + protocolVersion: number; +} + +/** Not sure for name. */ +export class Abstraction { + private readonly DEFAULT_TIMEOUT: number = 1_000; + private client: NiimbotAbstractClient; + private timeout: number = this.DEFAULT_TIMEOUT; + private statusPollTimer: NodeJS.Timeout | undefined; + + constructor(client: NiimbotAbstractClient) { + this.client = client; + } + + public getTimeout(): number { + return this.timeout; + } + + public setTimeout(value: number) { + this.timeout = value; + } + + public setDefaultTimeout() { + this.timeout = this.DEFAULT_TIMEOUT; + } + + /** Send packet and wait for response */ + private async send(packet: NiimbotPacket): Promise<NiimbotPacket> { + return this.client.sendPacketWaitResponse(packet, this.timeout); + } + + public async getPrintStatus(): Promise<PrintStatus> { + const packet = await this.send(PacketGenerator.printStatus()); + + if (packet.command === ResponseCommandId.In_PrintError) { + Validators.u8ArrayLengthEquals(packet.data, 1); + throw new PrintError(`Print error (${ResponseCommandId[packet.command]} packet received)`, packet.data[0]); + } + + Validators.u8ArrayLengthAtLeast(packet.data, 4); // can be 8, 10, but ignore it for now + + const r = new SequentialDataReader(packet.data); + const page = r.readI16(); + const pagePrintProgress = r.readI8(); + const pageFeedProgress = r.readI8(); + + if (packet.dataLength === 10) { + r.skip(2); + const error = r.readI8(); + + if (error !== 0) { + throw new PrintError(`Print error (${ResponseCommandId[packet.command]} packet flag)`, error); + } + } + + return { page, pagePrintProgress, pageFeedProgress }; + } + + public async connectResult(): Promise<ConnectResult> { + const packet = await this.send(PacketGenerator.connect()); + Validators.u8ArrayLengthAtLeast(packet.data, 1); + return packet.data[0] as ConnectResult; + } + + public async getPrinterStatusData(): Promise<PrinterStatusData> { + let protocolVersion = 0; + const packet = await this.send(PacketGenerator.getPrinterStatusData()); + let supportColor = 0; + + if (packet.dataLength > 12) { + supportColor = packet.data[10]; + + const n = packet.data[11] * 100 + packet.data[12]; + if (n >= 204 && n < 300) { + protocolVersion = 3; + } + if (n >= 301) { + protocolVersion = 4; + } + } + + return { + supportColor, + protocolVersion, + }; + } + + public async getPrinterModel(): Promise<number> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.PrinterModelId)); + Validators.u8ArrayLengthEquals(packet.data, 2); + return Utils.bytesToI16(packet.data); + } + + /** Read paper nfc tag info */ + public async rfidInfo(): Promise<RfidInfo> { + const packet = await this.send(PacketGenerator.rfidInfo()); + + const info: RfidInfo = { + tagPresent: false, + uuid: "", + barCode: "", + serialNumber: "", + allPaper: -1, + usedPaper: -1, + consumablesType: LabelType.Invalid, + }; + + if (packet.dataLength === 1) { + return info; + } + + const r = new SequentialDataReader(packet.data); + info.tagPresent = true; + info.uuid = Utils.bufToHex(r.readBytes(8), ""); + info.barCode = r.readVString(); + info.serialNumber = r.readVString(); + info.allPaper = r.readI16(); + info.usedPaper = r.readI16(); + info.consumablesType = r.readI8() as LabelType; + r.end(); + + return info; + } + + public async heartbeat(): Promise<HeartbeatData> { + const packet = await this.send(PacketGenerator.heartbeat(HeartbeatType.Advanced1)); + + const info: HeartbeatData = { + paperState: -1, + rfidReadState: -1, + lidClosed: false, + powerLevel: BatteryChargeLevel.Charge0, + }; + + // originally expected packet length is bound to model id, but we make it more robust and simple + const len = packet.dataLength; + const r = new SequentialDataReader(packet.data); + + if (len === 10) { + // d110 + r.skip(8); + info.lidClosed = r.readBool(); + info.powerLevel = r.readI8(); + } else if (len === 20) { + r.skip(18); + info.paperState = r.readI8(); + info.rfidReadState = r.readI8(); + } else if (len === 19) { + r.skip(15); + info.lidClosed = r.readBool(); + info.powerLevel = r.readI8(); + info.paperState = r.readI8(); + info.rfidReadState = r.readI8(); + } else if (len === 13) { + // b1 + r.skip(9); + info.lidClosed = r.readBool(); + info.powerLevel = r.readI8(); + info.paperState = r.readI8(); + info.rfidReadState = r.readI8(); + } else { + throw new Error("Invalid heartbeat length"); + } + r.end(); + + const model: number | undefined = this.client.getPrinterInfo().model_id; + + if (model !== undefined && ![512, 514, 513, 2304, 1792, 3584, 5120, 2560, 3840, 4352, 272].includes(model)) { + info.lidClosed = !info.lidClosed; + } + + return info; + } + + public async getBatteryChargeLevel(): Promise<BatteryChargeLevel> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.BatteryChargeLevel)); + Validators.u8ArrayLengthEquals(packet.data, 1); + return packet.data[0] as BatteryChargeLevel; + } + + public async getAutoShutDownTime(): Promise<AutoShutdownTime> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.AutoShutdownTime)); + Validators.u8ArrayLengthEquals(packet.data, 1); + return packet.data[0] as AutoShutdownTime; + } + + public async setAutoShutDownTime(time: AutoShutdownTime): Promise<void> { + await this.send(PacketGenerator.setAutoShutDownTime(time)); + } + + public async getLabelType(): Promise<LabelType> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.LabelType)); + Validators.u8ArrayLengthEquals(packet.data, 1); + return packet.data[0] as LabelType; + } + + public async getPrinterSerialNumber(): Promise<string> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.SerialNumber)); + Validators.u8ArrayLengthAtLeast(packet.data, 1); + return Utils.u8ArrayToString(packet.data); + } + + public async getPrinterBluetoothMacAddress(): Promise<string> { + const packet = await this.send(PacketGenerator.getPrinterInfo(PrinterInfoType.BluetoothAddress)); + Validators.u8ArrayLengthAtLeast(packet.data, 1); + return Utils.bufToHex(packet.data.reverse(), ":"); + } + + public async isSoundEnabled(soundType: SoundSettingsItemType): Promise<boolean> { + const packet = await this.send(PacketGenerator.getSoundSettings(soundType)); + Validators.u8ArrayLengthEquals(packet.data, 3); + const value = !!packet.data[2]; + return value; + } + + public async setSoundEnabled(soundType: SoundSettingsItemType, value: boolean): Promise<void> { + await this.send(PacketGenerator.setSoundSettings(soundType, value)); + } + + /** Clear settings */ + public async printerReset(): Promise<void> { + await this.send(PacketGenerator.printerReset()); + } + + public async print( + taskVersion: PrintTaskVersion, + image: EncodedImage, + options?: PrintOptions, + timeout?: number + ): Promise<void> { + this.setTimeout(timeout ?? 10_000); + const packets: NiimbotPacket[] = PacketGenerator.generatePrintSequence(taskVersion, image, options); + try { + for (const element of packets) { + await this.send(element); + } + } finally { + this.setDefaultTimeout(); + } + } + + /** + * Poll printer every {@link pollIntervalMs} and resolve when printer pages equals {@link pagesToPrint}, pagePrintProgress=100, pageFeedProgress=100. + * + * printprogress event is firing during this process. + * + * @param pagesToPrint Total pages to print. + * @param pollIntervalMs Poll interval in milliseconds. + */ + public async waitUntilPrintFinished(pagesToPrint: number, pollIntervalMs: number = 300): Promise<void> { + return new Promise((resolve, reject) => { + this.statusPollTimer = setInterval(() => { + this.getPrintStatus() + .then((status: PrintStatus) => { + this.client.dispatchTypedEvent( + "printprogress", + new PrintProgressEvent(status.page, pagesToPrint, status.pagePrintProgress, status.pageFeedProgress) + ); + + if (status.page === pagesToPrint && status.pagePrintProgress === 100 && status.pageFeedProgress === 100) { + clearInterval(this.statusPollTimer); + resolve(); + } + }) + .catch((e: unknown) => { + clearInterval(this.statusPollTimer); + reject(e as Error); + }); + }, pollIntervalMs); + }); + } + + public async printEnd(): Promise<void> { + await this.send(PacketGenerator.printEnd()); + } +} diff --git a/src/packets/data_reader.ts b/src/packets/data_reader.ts new file mode 100644 index 0000000..819b3fd --- /dev/null +++ b/src/packets/data_reader.ts @@ -0,0 +1,74 @@ +import { Utils } from ".."; + +/** Utility class to sequentially fetch data from byte array. EOF checks included. */ +export class SequentialDataReader { + private bytes: Uint8Array; + private offset: number; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + this.offset = 0; + } + + /** Check available bytes bytes and throw exception if EOF met */ + private willRead(count: number) { + // console.log(`willRead ${count} (offset becomes ${this.offset+count} / ${this.bytes.length})`) + if (this.offset + count > this.bytes.length) { + throw new Error("Tried to read too much data"); + } + } + + /** Skip bytes */ + skip(len: number): void { + this.willRead(len); + this.offset += len; + } + + /** Read fixed length bytes */ + readBytes(len: number): Uint8Array { + this.willRead(len); + const part = this.bytes.slice(this.offset, this.offset + len); + this.offset += len; + return part; + } + + /** Read variable length bytes */ + readVBytes(): Uint8Array { + const len = this.readI8(); + const part: Uint8Array = this.readBytes(len); + return part; + } + + /** Read variable length string */ + readVString(): string { + const part: Uint8Array = this.readVBytes(); + return Utils.u8ArrayToString(part); + } + + /** Read 8 bit int (big endian) */ + readI8(): number { + this.willRead(1); + const result = this.bytes[this.offset]; + this.offset += 1; + return result; + } + + readBool(): boolean { + return this.readI8() > 0; + } + + /** Read 16 bit int (big endian) */ + readI16(): number { + this.willRead(2); + const part = this.bytes.slice(this.offset, this.offset + 2); + this.offset += 2; + return Utils.bytesToI16(part); + } + + /** Check EOF condition */ + end() { + if (this.offset != this.bytes.length) { + throw new Error("Extra data left"); + } + } +} diff --git a/src/packets/index.ts b/src/packets/index.ts new file mode 100644 index 0000000..f3612b8 --- /dev/null +++ b/src/packets/index.ts @@ -0,0 +1,165 @@ +export enum RequestCommandId { + Invalid = -1, + Connect = 0xc1, + CancelPrint = 0xda, + Heartbeat = 0xdc, + LabelPositioningCalibration = 0x8e, //-114, + PageEnd = 0xe3, + PrinterLog = 0x05, + PageStart = 0x03, + PrintBitmapRow = 0x85, // -123 + PrintBitmapRowIndexed = 0x83, // -125, indexed if black pixels < 6 + PrintClear = 0x20, + PrintEmptyRow = 0x84, // -124 + PrintEnd = 0xf3, + PrinterInfo = 0x40, // See PrinterInfoType + PrinterConfig = 0xaf, + PrinterStatusData = 0xa5, + PrinterReset = 0x28, + PrintQuantity = 0x15, + PrintStart = 0x01, + PrintStatus = 0xa3, + RfidInfo = 0x1a, + RfidInfo2 = 0x1c, + RfidSuccessTimes = 0x54, + SetAutoShutdownTime = 0x27, /// + SetDensity = 0x21, + SetLabelType = 0x23 /* D11 - 1,5, for D110 able to set 1,2,3,5; see LabelType */, + SetPageSize = 0x13, // 2, 4 or 6 bytes + SoundSettings = 0x58, + AntiFake = 0x0b, // some info request (niimbot app), 01 long 02 short + WriteRFID = 0x70, // same as GetVolumeLevel??? +} + +export enum ResponseCommandId { + Invalid = -1, + In_NotSupported = 0x00, + In_Connect = 0xc2, + In_AntiFake = 0x0c, + In_HeartbeatAdvanced1 = 0xdd, + In_HeartbeatBasic = 0xde, + In_HeartbeatUnknown = 0xdf, + In_HeartbeatAdvanced2 = 0xd9, + In_PageStart = 0x04, + In_PrintClear = 0x30, + /** sent by printer after {@link RequestCommandId.PageEnd} with {@link ResponseCommandId.In_PageEnd} */ + In_PrinterCheckLine = 0xd3, + In_PrintEnd = 0xf4, + In_PrinterConfig = 0xbf, + In_PrinterInfoAutoShutDownTime = 0x47, + In_PrinterInfoBluetoothAddress = 0x4d, + In_PrinterInfoSpeed = 0x42, + In_PrinterInfoDensity = 0x41, + In_PrinterInfoLanguage = 0x46, + In_PrinterInfoChargeLevel = 0x4a, + In_PrinterInfoHardWareVersion = 0x4c, + In_PrinterInfoLabelType = 0x43, + In_PrinterInfoPrinterCode = 0x48, + In_PrinterInfoSerialNumber = 0x4b, + In_PrinterInfoSoftWareVersion = 0x49, + In_PrinterInfoArea = 0x4f, + In_PrinterStatusData = 0xb5, + In_PrinterReset = 0x38, + In_PrintStatus = 0xb3, + In_PrintError = 0xdb, // For example, sent on SetPageSize when page print is not started + In_PrintQuantity = 0x16, + In_PrintStart = 0x02, + In_RfidInfo = 0x1b, + In_RfidSuccessTimes = 0x64, + In_SetAutoShutdownTime = 0x37, + In_SetDensity = 0x31, + In_SetLabelType = 0x33, + In_SetPageSize = 0x14, + In_SoundSettings = 0x68, + In_PageEnd = 0xe4, +} + +export enum PrinterInfoType { + Density = 1, + Speed = 2, + LabelType = 3, + Language = 6, + AutoShutdownTime = 7, + /** See {@link PrinterId} */ + PrinterModelId = 8, + SoftWareVersion = 9, + BatteryChargeLevel = 10, + SerialNumber = 11, + HardWareVersion = 12, + BluetoothAddress = 13, + PrintMode = 14, + Area = 15, +} + +export enum SoundSettingsType { + SetSound = 0x01, + GetSoundState = 0x02, +} + +export enum SoundSettingsItemType { + BluetoothConnectionSound = 0x01, + PowerSound = 0x02, +} + +export enum LabelType { + Invalid = 0, + /** Default for D11 and similar */ + WithGaps = 1, + Black = 2, + Continuous = 3, + Perforated = 4, + Transparent = 5, + PvcTag = 6, + BlackMarkGap = 10, + HeatShrinkTube = 11, +} + +export enum HeartbeatType { + Advanced1 = 1, + Basic = 2, + Unknown = 3, + Advanced2 = 4, +} + +export enum AutoShutdownTime { + Shutdown15min = 1, + Shutdown30min = 2, + Shutdown45min = 3, + Shutdown60min = 4, +} + +/** Battery charge level */ +export enum BatteryChargeLevel { + Charge0 = 0, + Charge25 = 1, + Charge50 = 2, + Charge75 = 3, + Charge100 = 4, +} + +export enum ConnectResult { + Disconnect = 0, + Connected = 1, + ConnectedNew = 2, + ConnectedV3 = 3, + FirmwareErrors = 90, +} + +export enum PrintTaskVersion { + /** Used in D11 */ + V1 = 1, + /** Used in B21, D110new */ + V2, + /** Used in B16 */ + V3, + /** Used in B1 */ + V4, + /** Not used */ + V5, +} + +export * from "./packet"; +export * from "./packet_generator"; +export * from "./abstraction"; +export * from "./data_reader"; +export * from "./print_task_versions"; diff --git a/src/packets/packet.ts b/src/packets/packet.ts new file mode 100644 index 0000000..2081ca6 --- /dev/null +++ b/src/packets/packet.ts @@ -0,0 +1,194 @@ +import { Validators } from "../utils"; +import { RequestCommandId, ResponseCommandId } from "."; + +export class NiimbotPacket { + public static readonly HEAD = new Uint8Array([0x55, 0x55]); + public static readonly TAIL = new Uint8Array([0xaa, 0xaa]); + + private _command: RequestCommandId | ResponseCommandId; + private _data: Uint8Array; + private _validResponseIds: ResponseCommandId[]; + + /** There can be no response after this request. */ + private _oneWay: boolean; + + constructor( + command: RequestCommandId | ResponseCommandId, + data: Uint8Array | number[], + validResponseIds: ResponseCommandId[] = [] + ) { + this._command = command; + this._data = data instanceof Uint8Array ? data : new Uint8Array(data); + this._validResponseIds = validResponseIds; + this._oneWay = false; + } + + /** Data length (header, command, dataLen, checksum, tail are excluded). */ + public get dataLength(): number { + return this._data.length; + } + public get length(): number { + return ( + NiimbotPacket.HEAD.length + // head + 1 + // cmd + 1 + // dataLength + this.dataLength + + 1 + // checksum + NiimbotPacket.TAIL.length + ); + } + + public set oneWay(value: boolean) { + this._oneWay = value; + } + + public get oneWay(): boolean { + return this._oneWay; + } + + public get validResponseIds(): ResponseCommandId[] { + return this._validResponseIds; + } + + public get command(): RequestCommandId | ResponseCommandId { + return this._command; + } + + public get data(): Uint8Array { + return this._data; + } + + public get checksum(): number { + let checksum = 0; + checksum ^= this._command; + checksum ^= this._data.length; + this._data.forEach((i: number) => (checksum ^= i)); + return checksum; + } + + // [0x55, 0x55, CMD, DATA_LEN, DA =//= TA, CHECKSUM, 0xAA, 0xAA] + public toBytes(): Uint8Array { + const buf = new ArrayBuffer( + NiimbotPacket.HEAD.length + // head + 1 + // cmd + 1 + // dataLength + this._data.length + + 1 + // checksum + NiimbotPacket.TAIL.length + ); + + const arr = new Uint8Array(buf); + + let pos = 0; + + arr.set(NiimbotPacket.HEAD, pos); + pos += NiimbotPacket.HEAD.length; + + arr[pos] = this._command; + pos += 1; + + arr[pos] = this._data.length; + pos += 1; + + arr.set(this._data, pos); + pos += this._data.length; + + arr[pos] = this.checksum; + pos += 1; + + arr.set(NiimbotPacket.TAIL, pos); + + if (this._command === RequestCommandId.Connect) { + // const newArr = new Uint8Array(arr.length + 1); + // newArr[0] = 3; + // newArr.set(arr, 1); + return new Uint8Array([3, ...arr]); + } + + return arr; + } + + public static fromBytes(buf: Uint8Array): NiimbotPacket { + const head = new Uint8Array(buf.slice(0, 2)); + const tail = new Uint8Array(buf.slice(buf.length - 2)); + const minPacketSize = + NiimbotPacket.HEAD.length + // head + 1 + // cmd + 1 + // dataLength + 1 + // checksum + NiimbotPacket.TAIL.length; + + if (buf.length < minPacketSize) { + throw new Error(`Packet is too small (${buf.length} < ${minPacketSize})`); + } + + Validators.u8ArraysEqual(head, NiimbotPacket.HEAD, "Invalid packet head"); + + Validators.u8ArraysEqual(tail, NiimbotPacket.TAIL, "Invalid packet tail"); + + const cmd: number = buf[2]; + const dataLen: number = buf[3]; + + if (buf.length !== minPacketSize + dataLen) { + throw new Error(`Invalid packet size (${buf.length} < ${minPacketSize + dataLen})`); + } + + const data: Uint8Array = new Uint8Array(buf.slice(4, 4 + dataLen)); + const checksum: number = buf[4 + dataLen]; + const packet = new NiimbotPacket(cmd, data); + + if (packet.checksum !== checksum) { + throw new Error("Invalid packet checksum"); + } + + return packet; + } + + /** Parse data containing one or more packets */ + public static fromBytesMultiPacket(buf: Uint8Array): NiimbotPacket[] { + const chunks: Uint8Array[] = []; + + let head1pos = -1; + let head2pos = -1; + let tail1pos = -1; + let tail2pos = -1; + + // split data to chunks by head and tail bytes + for (let i = 0; i < buf.length; i++) { + const v = buf[i]; + if (v === NiimbotPacket.HEAD[0]) { + if (head1pos === -1) { + head1pos = i; + head2pos = -1; + } else { + head2pos = i; + } + } else if (v === NiimbotPacket.TAIL[0]) { + if (head1pos !== -1 && head2pos !== -1) { + if (tail1pos === -1) { + tail1pos = i; + tail2pos = -1; + } else { + tail2pos = i; + } + } + } + + if (head1pos !== -1 && head2pos !== -1 && tail1pos !== -1 && tail2pos !== -1) { + chunks.push(buf.slice(head1pos, tail2pos + 1)); + head1pos = -1; + head2pos = -1; + tail1pos = -1; + tail2pos = -1; + } + } + + const chunksDataLen: number = chunks.reduce((acc: number, b: Uint8Array) => acc + b.length, 0); + + if (buf.length !== chunksDataLen) { + throw new Error("Splitted chunks data length not equals buffer length"); + } + + return chunks.map((c) => this.fromBytes(c)); + } +} diff --git a/src/packets/packet_generator.ts b/src/packets/packet_generator.ts new file mode 100644 index 0000000..f6b2cc5 --- /dev/null +++ b/src/packets/packet_generator.ts @@ -0,0 +1,335 @@ +import { + AutoShutdownTime, + HeartbeatType, + LabelType, + NiimbotPacket, + PrinterInfoType, + RequestCommandId, + ResponseCommandId, + SoundSettingsItemType, + SoundSettingsType, + PrintTaskVersion, +} from "."; +import { EncodedImage, ImageEncoder, ImageRow } from "../image_encoder"; +import { Utils } from "../utils"; + +export type PrintOptions = { + labelType?: LabelType; + density?: number; + quantity?: number; +}; + +export class PacketGenerator { + public static generic( + requestId: RequestCommandId, + data: Uint8Array | number[], + responseIds: ResponseCommandId[] = [] + ): NiimbotPacket { + return new NiimbotPacket(requestId, data, responseIds); + } + + public static connect(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.Connect, [1], [ResponseCommandId.In_Connect]); + } + + public static getPrinterStatusData(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PrinterStatusData, [1], [ResponseCommandId.In_PrinterStatusData]); + } + + public static rfidInfo(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.RfidInfo, [1], [ResponseCommandId.In_RfidInfo]); + } + + public static setAutoShutDownTime(time: AutoShutdownTime): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.SetAutoShutdownTime, [time], [ResponseCommandId.In_SetAutoShutdownTime]); + } + + public static getPrinterInfo(type: PrinterInfoType): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.PrinterInfo, + [type], + [ + ResponseCommandId.In_PrinterInfoArea, + ResponseCommandId.In_PrinterInfoAutoShutDownTime, + ResponseCommandId.In_PrinterInfoBluetoothAddress, + ResponseCommandId.In_PrinterInfoChargeLevel, + ResponseCommandId.In_PrinterInfoDensity, + ResponseCommandId.In_PrinterInfoHardWareVersion, + ResponseCommandId.In_PrinterInfoLabelType, + ResponseCommandId.In_PrinterInfoLanguage, + ResponseCommandId.In_PrinterInfoPrinterCode, + ResponseCommandId.In_PrinterInfoSerialNumber, + ResponseCommandId.In_PrinterInfoSoftWareVersion, + ResponseCommandId.In_PrinterInfoSpeed, + ] + ); + } + + public static setSoundSettings(soundType: SoundSettingsItemType, on: boolean): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SoundSettings, + [SoundSettingsType.SetSound, soundType, on ? 1 : 0], + [ResponseCommandId.In_SoundSettings] + ); + } + + public static getSoundSettings(soundType: SoundSettingsItemType): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SoundSettings, + [SoundSettingsType.GetSoundState, soundType, 1], + [ResponseCommandId.In_SoundSettings] + ); + } + + public static heartbeat(type: HeartbeatType): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.Heartbeat, + [type], + [ + ResponseCommandId.In_HeartbeatBasic, + ResponseCommandId.In_HeartbeatUnknown, + ResponseCommandId.In_HeartbeatAdvanced1, + ResponseCommandId.In_HeartbeatAdvanced2, + ] + ); + } + + public static setDensity(value: number): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.SetDensity, [value], [ResponseCommandId.In_SetDensity]); + } + + public static setLabelType(value: number): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.SetLabelType, [value], [ResponseCommandId.In_SetLabelType]); + } + + public static setPageSizeV1(rows: number): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SetPageSize, + [...Utils.u16ToBytes(rows)], + [ResponseCommandId.In_SetPageSize] + ); + } + + /** + * B1 behavior: strange, first print is blank or printer prints many copies (use {@link setPageSizeV2} instead) + * + * D110 behavior: ordinary. + * + * @param rows Height in pixels + * @param cols Width in pixels + */ + public static setPageSizeV2(rows: number, cols: number): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SetPageSize, + [...Utils.u16ToBytes(rows), ...Utils.u16ToBytes(cols)], + [ResponseCommandId.In_SetPageSize] + ); + } + + /** + * @param rows Height in pixels + * @param cols Width in pixels + * @param copiesCount Page instances + */ + public static setPageSizeV3(rows: number, cols: number, copiesCount: number): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SetPageSize, + [...Utils.u16ToBytes(rows), ...Utils.u16ToBytes(cols), ...Utils.u16ToBytes(copiesCount)], + [ResponseCommandId.In_SetPageSize] + ); + } + + public static setPageSizeV5(rows: number, cols: number, copiesCount: number, someSize: number): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.SetPageSize, + [ + ...Utils.u16ToBytes(rows), + ...Utils.u16ToBytes(cols), + ...Utils.u16ToBytes(copiesCount), + ...Utils.u16ToBytes(someSize), + ], + [ResponseCommandId.In_SetPageSize] + ); + } + + public static setPrintQuantity(quantity: number): NiimbotPacket { + const [h, l] = Utils.u16ToBytes(quantity); + return new NiimbotPacket(RequestCommandId.PrintQuantity, [h, l]); + } + + public static printStatus(): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.PrintStatus, + [1], + [ResponseCommandId.In_PrintStatus, ResponseCommandId.In_PrintError] + ); + } + public static printerReset(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PrinterReset, [1], [ResponseCommandId.In_PrinterReset]); + } + + /** + * B1 behavior: after {@link pageEnd} paper stops at printhead position, on {@link printEnd} paper moved further. + * + * D110 behavior: ordinary. + * */ + public static printStart(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PrintStart, [1], [ResponseCommandId.In_PrintStart]); + } + + public static printStartV3(totalPages: number): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.PrintStart, + [...Utils.u16ToBytes(totalPages)], + [ResponseCommandId.In_PrintStart] + ); + } + + /** + * B1 behavior: when {@link totalPages} > 1 after {@link pageEnd} paper stops at printhead position and waits for next page. + * When last page ({@link totalPages}) printed paper moved further. + * + * D110 behavior: ordinary. + * + * @param totalPages Declare how many pages will be printed + */ + public static printStartV4(totalPages: number, pageColor: number = 0): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.PrintStart, + [...Utils.u16ToBytes(totalPages), 0x00, 0x00, 0x00, 0x00, pageColor], + [ResponseCommandId.In_PrintStart] + ); + } + + public static printStartV5(totalPages: number, pageColor: number = 0, quality: number = 0): NiimbotPacket { + return new NiimbotPacket( + RequestCommandId.PrintStart, + [...Utils.u16ToBytes(totalPages), 0x00, 0x00, 0x00, 0x00, pageColor, quality], + [ResponseCommandId.In_PrintStart] + ); + } + + public static printEnd(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PrintEnd, [1], [ResponseCommandId.In_PrintEnd]); + } + public static pageStart(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PageStart, [1], [ResponseCommandId.In_PageStart]); + } + public static pageEnd(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PageEnd, [1], [ResponseCommandId.In_PageEnd]); + } + + public static printEmptySpace(pos: number, repeats: number): NiimbotPacket { + const packet = new NiimbotPacket(RequestCommandId.PrintEmptyRow, [...Utils.u16ToBytes(pos), repeats]); + packet.oneWay = true; + return packet; + } + + public static printBitmapRow(pos: number, repeats: number, data: Uint8Array): NiimbotPacket { + const blackPixelCount: number = Utils.countSetBits(data); + + const packet = new NiimbotPacket(RequestCommandId.PrintBitmapRow, [ + ...Utils.u16ToBytes(pos), + // Black pixel count. Not sure what role it plays in printing. + // There is two formats of this part + // 1. <count> <count> <count> (sum must equals number of pixels, every number calculated by algorithm based on printhead resolution) + // 2. <0> <countH> <countL> (big endian) + 0, + ...Utils.u16ToBytes(blackPixelCount), + repeats, + ...data, + ]); + packet.oneWay = true; + return packet; + } + + /** Printer powers off if black pixel count > 6 */ + public static printBitmapRowIndexed(pos: number, repeats: number, data: Uint8Array): NiimbotPacket { + const blackPixelCount: number = Utils.countSetBits(data); + const indexes: Uint8Array = ImageEncoder.indexPixels(data); + + if (blackPixelCount > 6) { + throw new Error(`Black pixel count > 6 (${blackPixelCount})`); + } + + const packet = new NiimbotPacket(RequestCommandId.PrintBitmapRowIndexed, [ + ...Utils.u16ToBytes(pos), + 0, + ...Utils.u16ToBytes(blackPixelCount), + repeats, + ...indexes, + ]); + + packet.oneWay = true; + return packet; + } + + public static printClear(): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.PrintClear, [1]); + } + + public static writeRfid(data: Uint8Array): NiimbotPacket { + return new NiimbotPacket(RequestCommandId.WriteRFID, data); + } + + public static writeImageData(image: EncodedImage): NiimbotPacket[] { + return image.rowsData.map((p: ImageRow) => { + if (p.dataType === "pixels") { + if (p.blackPixelsCount > 6) { + return PacketGenerator.printBitmapRow(p.rowNumber, p.repeat, p.rowData!); + } else { + return PacketGenerator.printBitmapRowIndexed(p.rowNumber, p.repeat, p.rowData!); + } + } else { + return PacketGenerator.printEmptySpace(p.rowNumber, p.repeat); + } + }); + } + + /** + * You should send PrintEnd manually after this sequence after print is finished + */ + public static generatePrintSequenceV3(image: EncodedImage, options?: PrintOptions): NiimbotPacket[] { + return [ + PacketGenerator.setLabelType(options?.labelType ?? LabelType.WithGaps), + PacketGenerator.setDensity(options?.density ?? 2), + PacketGenerator.printStart(), + PacketGenerator.printClear(), + PacketGenerator.pageStart(), + PacketGenerator.setPageSizeV2(image.rows, image.cols), + PacketGenerator.setPrintQuantity(options?.quantity ?? 1), + ...PacketGenerator.writeImageData(image), + PacketGenerator.pageEnd(), + ]; + } + + /** + * You should send PrintEnd manually after this sequence after print is finished + */ + public static generatePrintSequenceV4(image: EncodedImage, options?: PrintOptions): NiimbotPacket[] { + return [ + PacketGenerator.setDensity(options?.density ?? 2), + PacketGenerator.setLabelType(options?.labelType ?? LabelType.WithGaps), + PacketGenerator.printStartV4(options?.quantity ?? 1), + PacketGenerator.pageStart(), + PacketGenerator.setPageSizeV3(image.rows, image.cols, options?.quantity ?? 1), + ...PacketGenerator.writeImageData(image), + PacketGenerator.pageEnd(), + ]; + } + + public static generatePrintSequence( + printTaskVersion: PrintTaskVersion, + image: EncodedImage, + options?: PrintOptions + ): NiimbotPacket[] { + switch (printTaskVersion) { + case PrintTaskVersion.V3: + return PacketGenerator.generatePrintSequenceV3(image, options); + case PrintTaskVersion.V4: + return PacketGenerator.generatePrintSequenceV4(image, options); + default: + throw new Error(`PrintTaskVersion ${printTaskVersion} Not implemented`); + } + } +} diff --git a/src/packets/print_task_versions.ts b/src/packets/print_task_versions.ts new file mode 100644 index 0000000..93c0440 --- /dev/null +++ b/src/packets/print_task_versions.ts @@ -0,0 +1,17 @@ +import { PrintTaskVersion, PrinterModel as M } from ".."; + +export const getPrintTaskVersion = (model: M): PrintTaskVersion | undefined => { + switch (model) { + case M.D11: + case M.D11_H: + case M.D11S: + return PrintTaskVersion.V1; + case M.D110: + case M.D110_M: + return PrintTaskVersion.V3; + case M.B1: + return PrintTaskVersion.V4; + } + + return undefined; +}; diff --git a/src/printer_models.ts b/src/printer_models.ts new file mode 100644 index 0000000..25ecd70 --- /dev/null +++ b/src/printer_models.ts @@ -0,0 +1,730 @@ +/* AUTO-GENERATED FILE. DO NOT EDIT! */ +/* use 'yarn gen-printer-models' to generate */ + +import { PrintDirection, LabelType as LT } from "."; + +export enum PrinterModel { + UNKNOWN = "UNKNOWN", + A20 = "A20", + A203 = "A203", + A63 = "A63", + A8 = "A8", + A8_P = "A8_P", + B1 = "B1", + B11 = "B11", + B16 = "B16", + B18 = "B18", + B18S = "B18S", + B203 = "B203", + B21 = "B21", + B21_PRO = "B21_PRO", + B21_C2B = "B21_C2B", + B21_L2B = "B21_L2B", + B21S = "B21S", + B21S_C2B = "B21S_C2B", + B3 = "B3", + B31 = "B31", + B32 = "B32", + B32R = "B32R", + B3S = "B3S", + B3S_P = "B3S_P", + B50 = "B50", + B50W = "B50W", + BETTY = "BETTY", + D101 = "D101", + D11 = "D11", + D11_H = "D11_H", + D110 = "D110", + D110_M = "D110_M", + D11S = "D11S", + D41 = "D41", + D61 = "D61", + DXX = "DXX", + ET10 = "ET10", + FUST = "FUST", + HI_D110 = "HI_D110", + HI_NB_D11 = "HI_NB_D11", + JC_M90 = "JC_M90", + JCB3S = "JCB3S", + K3 = "K3", + K3_W = "K3_W", + M2_H = "M2_H", + MP3K = "MP3K", + MP3K_W = "MP3K_W", + N1 = "N1", + P1 = "P1", + P18 = "P18", + P1S = "P1S", + S1 = "S1", + S3 = "S3", + S6 = "S6", + S6_P = "S6_P", + T2S = "T2S", + T6 = "T6", + T7 = "T7", + T8 = "T8", + T8S = "T8S", + TP2M_H = "TP2M_H", + Z401 = "Z401", +}; + +export interface PrinterModelMeta { + model: PrinterModel; + id: [number, ...number[]]; + dpi: number; + printDirection: PrintDirection; + printheadPixels: number; + paperTypes: number[]; + densityMin: number; + densityMax: number; + densityDefault: number; +} + +export const modelsLibrary: PrinterModelMeta[] = [ + { + model: PrinterModel.A20, + id: [2817], + dpi: 203, + printDirection: "top", + printheadPixels: 400, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.A203, + id: [2818], + dpi: 203, + printDirection: "top", + printheadPixels: 400, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.A63, + id: [2054], + dpi: 300, + printDirection: "top", + printheadPixels: 851, + paperTypes: [LT.WithGaps, LT.Transparent, LT.Black], + densityMin: 1, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.A8, + id: [256], + dpi: 203, + printDirection: "top", + printheadPixels: 600, + paperTypes: [LT.Black, LT.WithGaps, LT.Continuous], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.A8_P, + id: [273], + dpi: 203, + printDirection: "top", + printheadPixels: 616, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B1, + id: [4096], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B11, + id: [51457], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated, LT.Transparent], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.B16, + id: [1792], + dpi: 203, + printDirection: "left", + printheadPixels: 96, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.B18, + id: [3584], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.Transparent, LT.BlackMarkGap, LT.HeatShrinkTube], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.B18S, + id: [3585], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.Transparent, LT.BlackMarkGap, LT.HeatShrinkTube], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.B203, + id: [2816], + dpi: 203, + printDirection: "top", + printheadPixels: 400, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21, + id: [768], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21_PRO, + id: [785], + dpi: 300, + printDirection: "top", + printheadPixels: 591, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21_C2B, + id: [771, 775], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Continuous, LT.Transparent, LT.Black], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21_L2B, + id: [769], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21S, + id: [777], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B21S_C2B, + id: [776], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B3, + id: [52993], + dpi: 203, + printDirection: "top", + printheadPixels: 600, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B31, + id: [5632], + dpi: 203, + printDirection: "top", + printheadPixels: 600, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B32, + id: [2049], + dpi: 300, + printDirection: "top", + printheadPixels: 851, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.B32R, + id: [2050], + dpi: 300, + printDirection: "top", + printheadPixels: 851, + paperTypes: [LT.WithGaps], + densityMin: 1, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.B3S, + id: [256, 260, 262], + dpi: 203, + printDirection: "top", + printheadPixels: 576, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B3S_P, + id: [272], + dpi: 203, + printDirection: "top", + printheadPixels: 576, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.B50, + id: [51713], + dpi: 203, + printDirection: "top", + printheadPixels: 400, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.B50W, + id: [51714], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.BETTY, + id: [2561], + dpi: 203, + printDirection: "left", + printheadPixels: 192, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.D101, + id: [2560], + dpi: 203, + printDirection: "left", + printheadPixels: 192, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.D11, + id: [512], + dpi: 203, + printDirection: "left", + printheadPixels: 96, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.D11_H, + id: [528], + dpi: 300, + printDirection: "left", + printheadPixels: 178, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.D110, + id: [2304, 2305], + dpi: 203, + printDirection: "left", + printheadPixels: 96, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.D110_M, + id: [2320], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.D11S, + id: [514], + dpi: 203, + printDirection: "left", + printheadPixels: 96, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.ET10, + id: [5376], + dpi: 203, + printDirection: "top", + printheadPixels: 1600, + paperTypes: [LT.Continuous], + densityMin: 3, + densityMax: 3, + densityDefault: 3, + }, + { + model: PrinterModel.FUST, + id: [513], + dpi: 203, + printDirection: "left", + printheadPixels: 96, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.HI_D110, + id: [2305], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 3, + }, + { + model: PrinterModel.HI_NB_D11, + id: [512], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.JC_M90, + id: [51461], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.JCB3S, + id: [256], + dpi: 203, + printDirection: "top", + printheadPixels: 576, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 2, + }, + { + model: PrinterModel.K3, + id: [4864], + dpi: 203, + printDirection: "top", + printheadPixels: 656, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.K3_W, + id: [4865], + dpi: 203, + printDirection: "top", + printheadPixels: 656, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.M2_H, + id: [4608], + dpi: 300, + printDirection: "top", + printheadPixels: 591, + paperTypes: [LT.WithGaps, LT.Transparent, LT.Black, LT.BlackMarkGap], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.MP3K, + id: [4866], + dpi: 203, + printDirection: "top", + printheadPixels: 656, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.MP3K_W, + id: [4867], + dpi: 203, + printDirection: "top", + printheadPixels: 656, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.N1, + id: [3586], + dpi: 203, + printDirection: "left", + printheadPixels: 120, + paperTypes: [LT.WithGaps, LT.HeatShrinkTube, LT.Transparent, LT.BlackMarkGap], + densityMin: 1, + densityMax: 3, + densityDefault: 2, + }, + { + model: PrinterModel.P1, + id: [1024], + dpi: 300, + printDirection: "left", + printheadPixels: 697, + paperTypes: [LT.PvcTag], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.P18, + id: [1026], + dpi: 300, + printDirection: "left", + printheadPixels: 662, + paperTypes: [LT.PvcTag], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.P1S, + id: [1025], + dpi: 300, + printDirection: "left", + printheadPixels: 662, + paperTypes: [LT.PvcTag], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.S1, + id: [51458], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.S3, + id: [51460], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.S6, + id: [261, 259, 258, 257], + dpi: 203, + printDirection: "top", + printheadPixels: 576, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.S6_P, + id: [274], + dpi: 203, + printDirection: "top", + printheadPixels: 600, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.T2S, + id: [53250], + dpi: 203, + printDirection: "top", + printheadPixels: 832, + paperTypes: [LT.WithGaps, LT.Black], + densityMin: 1, + densityMax: 20, + densityDefault: 15, + }, + { + model: PrinterModel.T6, + id: [51715], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.T7, + id: [51717], + dpi: 203, + printDirection: "top", + printheadPixels: 384, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.T8, + id: [51718], + dpi: 300, + printDirection: "top", + printheadPixels: 567, + paperTypes: [LT.WithGaps, LT.Black, LT.Continuous, LT.Perforated], + densityMin: 6, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.T8S, + id: [2053], + dpi: 300, + printDirection: "top", + printheadPixels: 851, + paperTypes: [LT.WithGaps], + densityMin: 1, + densityMax: 15, + densityDefault: 10, + }, + { + model: PrinterModel.TP2M_H, + id: [4609], + dpi: 300, + printDirection: "top", + printheadPixels: 591, + paperTypes: [LT.WithGaps, LT.Black, LT.Transparent], + densityMin: 1, + densityMax: 5, + densityDefault: 3, + }, + { + model: PrinterModel.Z401, + id: [2051], + dpi: 300, + printDirection: "top", + printheadPixels: 851, + paperTypes: [LT.WithGaps, LT.Transparent], + densityMin: 1, + densityMax: 15, + densityDefault: 10, + }, +]; + +export const getPrinterMetaById = (id: number): PrinterModelMeta | undefined => { + return modelsLibrary.find((o) => o.id.includes(id)); +}; + +export const getPrinterMetaByModel = (model: PrinterModel): PrinterModelMeta | undefined => { + return modelsLibrary.find((o) => o.model === model); +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4f05c3e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,107 @@ +export class Utils { + public static numberToHex(n: number): string { + const hex = n.toString(16); + return hex.length === 1 ? `0${hex}` : hex; + } + + public static bufToHex(buf: DataView | Uint8Array | number[], separator: string = " "): string { + const arr: number[] = buf instanceof DataView ? this.dataViewToNumberArray(buf) : Array.from(buf); + return arr.map((n) => Utils.numberToHex(n)).join(separator); + } + + public static hexToBuf(str: string): Uint8Array { + const match = str.match(/[\da-f]{2}/gi); + + if (!match) { + return new Uint8Array(); + } + + return new Uint8Array( + match.map((h) => { + return parseInt(h, 16); + }) + ); + } + + public static dataViewToNumberArray(dw: DataView): number[] { + const a: number[] = []; + for (let i = 0; i < dw.byteLength; i++) { + a.push(dw.getUint8(i)); + } + return a; + } + + public static dataViewToU8Array(dw: DataView): Uint8Array { + return Uint8Array.from(this.dataViewToNumberArray(dw)); + } + + public static u8ArrayToString(arr: Uint8Array): string { + return new TextDecoder().decode(arr); + } + + /** Count non-zero bits in the byte array */ + public static countSetBits(arr: Uint8Array): number { + // not so efficient, but readable + let count: number = 0; + + arr.forEach((value) => { + // shift until value becomes zero + while (value > 0) { + // check last bit + if ((value & 1) === 1) { + count++; + } + value >>= 1; + } + }); + + return count; + } + + /** Big endian */ + public static u16ToBytes(n: number): [number, number] { + const h = (n >> 8) & 0xff; + const l = n % 256 & 0xff; + return [h, l]; + } + + /** Big endian */ + public static bytesToI16(arr: Uint8Array): number { + Validators.u8ArrayLengthEquals(arr, 2); + return arr[0] * 256 + arr[1]; + } + + public static u8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((el, i) => el === b[i]); + } + + public static sleep(ms: number): Promise<undefined> { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + public static isBluetoothSupported(): boolean { + return typeof navigator.bluetooth?.requestDevice !== "undefined"; + } + + public static isSerialSupported(): boolean { + return typeof navigator.serial?.requestPort !== "undefined"; + } +} + +export class Validators { + public static u8ArraysEqual(a: Uint8Array, b: Uint8Array, message?: string): void { + if (!Utils.u8ArraysEqual(a, b)) { + throw new Error(message ?? "Arrays must be equal"); + } + } + public static u8ArrayLengthEquals(a: Uint8Array, len: number, message?: string): void { + if (a.length !== len) { + throw new Error(message ?? `Array length must be ${len}`); + } + } + public static u8ArrayLengthAtLeast(a: Uint8Array, len: number, message?: string): void { + if (a.length < len) { + throw new Error(message ?? `Array length must be at least ${len}`); + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0b15f57 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["src/**/*"], + "compilerOptions": { + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "lib": ["ES2021", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "module": "commonjs", /* Specify what module code is generated. */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "strict": true, /* Enable all strict type-checking options. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/utils/gen-printer-models.js b/utils/gen-printer-models.js new file mode 100644 index 0000000..ca79eac --- /dev/null +++ b/utils/gen-printer-models.js @@ -0,0 +1,93 @@ +// https://print.niimbot.com/api/hardware/list + +fetch("https://oss-print.niimbot.com/public_resources/static_resources/devices.json") + .then((resp) => resp.json()) + .then((items) => { + items.sort((a, b) => a.name.localeCompare(b.name)); + + const dir_d = { + 270: "left", + 180: "top", + 90: "left", + 0: "top", + }; + + const ppmm_d = { + 203: 8, + 300: 11.81, + }; + + const labeltypes_d = { + "1": "LT.WithGaps", + "2": "LT.Black", + "3": "LT.Continuous", + "4": "LT.Perforated", + "5": "LT.Transparent", + "6": "LT.PvcTag", + "10": "LT.BlackMarkGap", + "11": "LT.HeatShrinkTube", + } + + console.log("/* AUTO-GENERATED FILE. DO NOT EDIT! */"); + console.log("/* use 'yarn gen-printer-models' to generate */\n"); + console.log('import { PrintDirection, LabelType as LT } from ".";\n'); + + console.log("export enum PrinterModel {"); + console.log(' UNKNOWN = "UNKNOWN",'); + for (const item of items) { + const name = item.name.toUpperCase().replaceAll("-", "_"); + console.log(` ${name} = "${name}",`); + } + + console.log("};"); + + console.log(` +export interface PrinterModelMeta { + model: PrinterModel; + id: [number, ...number[]]; + dpi: number; + printDirection: PrintDirection; + printheadPixels: number; + paperTypes: number[]; + densityMin: number; + densityMax: number; + densityDefault: number; +} +`); + + console.log("export const modelsLibrary: PrinterModelMeta[] = ["); + for (const item of items) { + if (item.codes.length === 0) { + continue; + } + + const name = item.name.toUpperCase().replaceAll("-", "_"); + const dir = dir_d[item.printDirection]; + const ppmm = ppmm_d[item.paccuracyName]; + const paperTypes = item.paperType.split(',').map(e => labeltypes_d[e]).join(", "); + + console.log(" {"); + console.log(` model: PrinterModel.${name},`); + console.log(` id: [${item.codes.join(', ')}],`); + console.log(` dpi: ${item.paccuracyName},`); + console.log(` printDirection: "${dir}",`); + console.log(` printheadPixels: ${Math.ceil(item.widthSetEnd * ppmm)},`); + console.log(` paperTypes: [${paperTypes}],`); + console.log(` densityMin: ${item.solubilitySetStart},`); + console.log(` densityMax: ${item.solubilitySetEnd},`); + console.log(` densityDefault: ${item.solubilitySetDefault},`); + console.log(" },"); + } + + console.log("];"); + + console.log(` +export const getPrinterMetaById = (id: number): PrinterModelMeta | undefined => { + return modelsLibrary.find((o) => o.id.includes(id)); +}; + +export const getPrinterMetaByModel = (model: PrinterModel): PrinterModelMeta | undefined => { + return modelsLibrary.find((o) => o.model === model); +};`); + + }); diff --git a/utils/parse-dump.mjs b/utils/parse-dump.mjs new file mode 100644 index 0000000..1c8df3f --- /dev/null +++ b/utils/parse-dump.mjs @@ -0,0 +1,102 @@ +import { Utils, NiimbotPacket, RequestCommandId, ResponseCommandId } from "../dist/index.js"; +import { spawn } from "child_process"; + +const TSHARK_PATH = "C:\\Program Files\\Wireshark\\tshark.exe"; + +if (process.argv.length < 4 || !["usb", "ble"].includes(process.argv[2])) { + console.error("usage: yarn parse-dump <ble|usb> <filename> [min|min-out]"); +} + +const capType = process.argv[2]; +const capPath = process.argv[3]; +const args = ["-2", "-r", capPath, "-P", "-T", "fields", "-e", /*"frame.time_relative"*/ "frame.time_delta"]; +const display = process.argv.length > 4 ? process.argv[4] : ""; + +if (capType === "ble") { + args.push("-R", "btspp.data", "-e", "hci_h4.direction", "-e", "btspp.data"); +} else { + args.push("-R", "usb.capdata", "-e", "usb.dst", "-e", "usb.capdata"); +} + +const spawned = spawn(TSHARK_PATH, args); + +let output = ""; + +spawned.stdout.on("data", (data) => { + output += data.toString(); +}); + +spawned.stderr.on("data", (data) => { + console.error(data); +}); + +spawned.on("close", (code) => { + console.log(`child process exited with code ${code}`); + + if (code !== 0) { + console.error(output); + return; + } + + const lines = output.split(/\r?\n/); + let printStarted = false; + + for (const line of lines) { + const splitted = line.split("\t"); + + if (splitted.length !== 3) { + continue; + } + + let [time, direction, hexData] = splitted; + direction = ["host", "0x01"].includes(direction) ? "<<" : ">>"; + + let comment = ""; + + try { + const data = Utils.hexToBuf(hexData); + const packets = NiimbotPacket.fromBytesMultiPacket(data); + + if (packets.length === 0) { + comment = "Parse error (no packets found)"; + } else if (packets.length > 1) { + comment = `Multi-packet (x${packets.length}); `; + } + + for (const packet of packets) { + if (direction === ">>") { + comment += RequestCommandId[packet.command] ?? "???"; + if (packet.command === RequestCommandId.PrintStart) { + printStarted = true; + const versions = { 1: "v1", 2: "v3", 7: "v4", 8: "v5" }; + comment += "_" + versions[packet.dataLength]; + } else if (packet.command === RequestCommandId.SetPageSize) { + const versions = { 2: "v1", 4: "v2", 6: "v3", 8: "v5" }; + comment += "_" + versions[packet.dataLength]; + }else if (packet.command === RequestCommandId.PrintEnd) { + printStarted = false; + } + } else { + comment += ResponseCommandId[packet.command] ?? "???"; + } + comment += `(${packet.dataLength}); `; + } + } catch (e) { + comment = "Invalid packet"; + } + + if (display === "min") { + console.log(`${direction} ${comment}`); + } else if (display === "min-out") { + if (direction === ">>") { + console.log(`${direction} ${comment}`); + } + } else if (display === "print-task") { + if (direction === ">>" && printStarted) { + console.log(`${direction} ${comment}`); + } + } else { + console.log(`[${time}] ${direction} ${hexData}\t// ${comment}`); + } + } +}); diff --git a/utils/translate-devices-json.mjs b/utils/translate-devices-json.mjs new file mode 100644 index 0000000..4c98ba0 --- /dev/null +++ b/utils/translate-devices-json.mjs @@ -0,0 +1,23 @@ +const locale = ( + await (await fetch("https://oss-print.niimbot.com/public_resources/static_resources/languagePack/en.json")).json() +)["lang"]; + +const translateDeep = (obj) => { + if (typeof obj === "object") { + for (const key in obj) { + if (typeof obj[key] === "object") { + translateDeep(obj[key]); + } else if (typeof obj[key] === "string" && key === "multilingualCode") { + const translated = locale[obj[key]]; + obj["translated"] = translated ? translated["value"] : "TRANSLATION_NOT_FOUND"; + } + } + } + return obj; +}; + +const devices = await (await fetch("https://oss-print.niimbot.com/public_resources/static_resources/devices.json")).json(); + +const translated = devices.map(translateDeep); + +console.log(JSON.stringify(translated, null, 2)); diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..ca7e47a --- /dev/null +++ b/yarn.lock @@ -0,0 +1,47 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@^20.14.2": + version "20.14.2" + resolved "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz" + integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q== + dependencies: + undici-types "~5.26.4" + +"@types/w3c-web-serial@^1.0.6": + version "1.0.6" + resolved "https://registry.npmjs.org/@types/w3c-web-serial/-/w3c-web-serial-1.0.6.tgz" + integrity sha512-5IlDdQ2C56sCVwc7CUlqT9Axxw+0V/FbWRbErklYIzZ5mKL9s4l7epXHygn+4X7L2nmAPnVvRl55XUVo0760Rg== + +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + +async-mutex@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz" + integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA== + dependencies: + tslib "^2.4.0" + +tslib@^2.4.0: + version "2.6.3" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + +typescript-event-target@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz" + integrity sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg== + +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==