Add code, npm publishing

This commit is contained in:
MultiMote 2024-09-14 20:33:42 +03:00
parent 0cb12cda1e
commit a5c572aa00
25 changed files with 3148 additions and 0 deletions

33
.github/workflows/publish-npmjs.yml vendored Normal file

@ -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 }}

4
.gitignore vendored Normal file

@ -0,0 +1,4 @@
/.vscode
/dist
/node_modules
/yarn-error.log

89
README.md Normal file

@ -0,0 +1,89 @@
## NiimBlueLib [![NPM](https://img.shields.io/npm/v/@mmote/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
```

3
clean-dist.mjs Normal file

@ -0,0 +1,3 @@
import fs from "fs";
fs.rmSync("dist", { recursive: true, force: true });

18
eslint.config.mjs Normal file

@ -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}"] },
];

45
package.json Normal file

@ -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"
}

@ -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);
}
}
}

94
src/client/events.ts Normal file

@ -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;
}

155
src/client/index.ts Normal file

@ -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";

184
src/client/serial_impl.ts Normal file

@ -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);
}
}
}

116
src/image_encoder.ts Normal file

@ -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);
}
}

5
src/index.ts Normal file

@ -0,0 +1,5 @@
export * from "./client";
export * from "./packets";
export * from "./image_encoder";
export * from "./utils";
export * from "./printer_models";

342
src/packets/abstraction.ts Normal file

@ -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());
}
}

@ -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");
}
}
}

165
src/packets/index.ts Normal file

@ -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";

194
src/packets/packet.ts Normal file

@ -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));
}
}

@ -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`);
}
}
}

@ -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;
};

730
src/printer_models.ts Normal file

@ -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);
};

107
src/utils.ts Normal file

@ -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}`);
}
}
}

14
tsconfig.json Normal file

@ -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. */
}
}

@ -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);
};`);
});

102
utils/parse-dump.mjs Normal file

@ -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}`);
}
}
});

@ -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));

47
yarn.lock Normal file

@ -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==