From a5c572aa00d8a849624ee1d4fc7589ae09fdc7e4 Mon Sep 17 00:00:00 2001
From: MultiMote <contact@mmote.ru>
Date: Sat, 14 Sep 2024 20:33:42 +0300
Subject: [PATCH] Add code, npm publishing

---
 .github/workflows/publish-npmjs.yml |  33 ++
 .gitignore                          |   4 +
 README.md                           |  89 ++++
 clean-dist.mjs                      |   3 +
 eslint.config.mjs                   |  18 +
 package.json                        |  45 ++
 src/client/bluetooth_impl.ts        | 159 ++++++
 src/client/events.ts                |  94 ++++
 src/client/index.ts                 | 155 ++++++
 src/client/serial_impl.ts           | 184 +++++++
 src/image_encoder.ts                | 116 +++++
 src/index.ts                        |   5 +
 src/packets/abstraction.ts          | 342 +++++++++++++
 src/packets/data_reader.ts          |  74 +++
 src/packets/index.ts                | 165 +++++++
 src/packets/packet.ts               | 194 ++++++++
 src/packets/packet_generator.ts     | 335 +++++++++++++
 src/packets/print_task_versions.ts  |  17 +
 src/printer_models.ts               | 730 ++++++++++++++++++++++++++++
 src/utils.ts                        | 107 ++++
 tsconfig.json                       |  14 +
 utils/gen-printer-models.js         |  93 ++++
 utils/parse-dump.mjs                | 102 ++++
 utils/translate-devices-json.mjs    |  23 +
 yarn.lock                           |  47 ++
 25 files changed, 3148 insertions(+)
 create mode 100644 .github/workflows/publish-npmjs.yml
 create mode 100644 .gitignore
 create mode 100644 README.md
 create mode 100644 clean-dist.mjs
 create mode 100644 eslint.config.mjs
 create mode 100644 package.json
 create mode 100644 src/client/bluetooth_impl.ts
 create mode 100644 src/client/events.ts
 create mode 100644 src/client/index.ts
 create mode 100644 src/client/serial_impl.ts
 create mode 100644 src/image_encoder.ts
 create mode 100644 src/index.ts
 create mode 100644 src/packets/abstraction.ts
 create mode 100644 src/packets/data_reader.ts
 create mode 100644 src/packets/index.ts
 create mode 100644 src/packets/packet.ts
 create mode 100644 src/packets/packet_generator.ts
 create mode 100644 src/packets/print_task_versions.ts
 create mode 100644 src/printer_models.ts
 create mode 100644 src/utils.ts
 create mode 100644 tsconfig.json
 create mode 100644 utils/gen-printer-models.js
 create mode 100644 utils/parse-dump.mjs
 create mode 100644 utils/translate-devices-json.mjs
 create mode 100644 yarn.lock

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 [![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
+```
\ 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==