From dad965933eb9cfb1049be2ce0908b1a8ed153ebf Mon Sep 17 00:00:00 2001
From: MultiMote <contact@mmote.ru>
Date: Sun, 17 Nov 2024 10:32:41 +0300
Subject: [PATCH] Capacitor BLE client implementation, instantiateClient
 function

---
 package.json                     |   2 +
 src/client/abstract_client.ts    | 287 ++++++++++++++++++++++++++++
 src/client/bluetooth_impl.ts     |   5 +-
 src/client/capacitor_ble_impl.ts | 162 ++++++++++++++++
 src/client/index.ts              | 312 +++----------------------------
 src/client/serial_impl.ts        |   5 +-
 src/utils.ts                     |  28 +++
 yarn.lock                        |  19 ++
 8 files changed, 528 insertions(+), 292 deletions(-)
 create mode 100644 src/client/abstract_client.ts
 create mode 100644 src/client/capacitor_ble_impl.ts

diff --git a/package.json b/package.json
index d92d131..1f28be0 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,8 @@
     "typescript": "^5.4.5"
   },
   "dependencies": {
+    "@capacitor-community/bluetooth-le": "^6.0.2",
+    "@capacitor/core": "^6.0.0",
     "async-mutex": "^0.5.0",
     "eventemitter3": "^5.0.1"
   },
diff --git a/src/client/abstract_client.ts b/src/client/abstract_client.ts
new file mode 100644
index 0000000..efa748f
--- /dev/null
+++ b/src/client/abstract_client.ts
@@ -0,0 +1,287 @@
+import { EventEmitter } from "eventemitter3";
+import { Mutex } from "async-mutex";
+import {
+  Abstraction,
+  AutoShutdownTime,
+  BatteryChargeLevel,
+  ConnectResult,
+  LabelType,
+  NiimbotPacket,
+  PrinterErrorCode,
+  PrintError,
+  ResponseCommandId,
+} from "../packets";
+import { PrinterModelMeta, getPrinterMetaById } from "../printer_models";
+import {
+  ClientEventMap,
+  PacketSentEvent,
+  PrinterInfoFetchedEvent,
+  HeartbeatEvent,
+  HeartbeatFailedEvent,
+  PacketReceivedEvent,
+} from "../events";
+import { findPrintTask, PrintTaskName } from "../print_tasks";
+import { Utils, Validators } from "../utils";
+
+/**
+ * Represents the connection result information.
+ *
+ * @category Client
+ */
+export type ConnectionInfo = {
+  deviceName?: string;
+  result: ConnectResult;
+};
+
+/**
+ * Interface representing printer information.
+ *
+ * @category Client
+ */
+export interface PrinterInfo {
+  connectResult?: ConnectResult;
+  protocolVersion?: number;
+  modelId?: number;
+  serial?: string;
+  mac?: string;
+  charge?: BatteryChargeLevel;
+  autoShutdownTime?: AutoShutdownTime;
+  labelType?: LabelType;
+  softwareVersion?: string;
+  hardwareVersion?: string;
+}
+
+/**
+ * Abstract class representing a client with common functionality for interacting with a printer.
+ * Hardware interface must be defined after extending this class.
+ *
+ * @category Client
+ */
+export abstract class NiimbotAbstractClient extends EventEmitter<ClientEventMap> {
+  public readonly abstraction: Abstraction;
+  protected info: PrinterInfo = {};
+  private heartbeatTimer?: NodeJS.Timeout;
+  private heartbeatFails: number = 0;
+  private heartbeatIntervalMs: number = 2_000;
+  protected mutex: Mutex = new Mutex();
+  protected debug: boolean = false;
+
+  /** @see https://github.com/MultiMote/niimblue/issues/5 */
+  protected packetIntervalMs: number = 10;
+
+  constructor() {
+    super();
+    this.abstraction = new Abstraction(this);
+    this.on("connect", () => this.startHeartbeat());
+    this.on("disconnect", () => this.stopHeartbeat());
+  }
+
+  /**
+   * Connect to printer port.
+   **/
+  public abstract connect(): Promise<ConnectionInfo>;
+
+  /**
+   * Disconnect from printer port.
+   **/
+  public abstract disconnect(): Promise<void>;
+
+  /**
+   * Check if the client is connected.
+   */
+  public abstract isConnected(): boolean;
+
+  /**
+   * Send packet and wait for response for {@link timeoutMs} milliseconds.
+   *
+   * If {@link NiimbotPacket.validResponseIds() validResponseIds} is defined, it will wait for packet with this command id.
+   *
+   * @throws {@link PrintError} when {@link ResponseCommandId.In_PrintError} or {@link ResponseCommandId.In_NotSupported} received.
+   *
+   * @returns {NiimbotPacket} Printer response object.
+   */
+  public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs: number = 1000): Promise<NiimbotPacket> {
+    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) => {
+          const pktIn = evt.packet;
+          const cmdIn = pktIn.command as ResponseCommandId;
+
+          if (
+            packet.validResponseIds.length === 0 ||
+            packet.validResponseIds.includes(cmdIn) ||
+            [ResponseCommandId.In_PrintError, ResponseCommandId.In_NotSupported].includes(cmdIn)
+          ) {
+            clearTimeout(timeout);
+            this.off("packetreceived", listener);
+
+            if (cmdIn === ResponseCommandId.In_PrintError) {
+              Validators.u8ArrayLengthEquals(pktIn.data, 1);
+              const errorName = PrinterErrorCode[pktIn.data[0]] ?? "unknown";
+              reject(new PrintError(`Print error ${pktIn.data[0]}: ${errorName}`, pktIn.data[0]));
+            } else if (cmdIn === ResponseCommandId.In_NotSupported) {
+              reject(new PrintError("Feature not supported", 0));
+            } else {
+              resolve(pktIn);
+            }
+          }
+        };
+
+        timeout = setTimeout(() => {
+          this.off("packetreceived", listener);
+          reject(new Error(`Timeout waiting response (waited for ${Utils.bufToHex(packet.validResponseIds, ", ")})`));
+        }, timeoutMs ?? 1000);
+
+        this.on("packetreceived", listener);
+      });
+    });
+  }
+
+  /**
+   * Send raw bytes to the printer port.
+   *
+   * @param data Bytes to send.
+   * @param force Ignore mutex lock. It used internally and 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.emit("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;
+    }
+  }
+
+  /**
+   * Fetches printer information and stores it.
+   */
+  public async fetchPrinterInfo(): Promise<PrinterInfo> {
+    this.info.modelId = await this.abstraction.getPrinterModel();
+
+    this.info.serial = (await this.abstraction.getPrinterSerialNumber().catch(console.error)) ?? undefined;
+    this.info.mac = (await this.abstraction.getPrinterBluetoothMacAddress().catch(console.error)) ?? undefined;
+    this.info.charge = (await this.abstraction.getBatteryChargeLevel().catch(console.error)) ?? undefined;
+    this.info.autoShutdownTime = (await this.abstraction.getAutoShutDownTime().catch(console.error)) ?? undefined;
+    this.info.labelType = (await this.abstraction.getLabelType().catch(console.error)) ?? undefined;
+    this.info.hardwareVersion = (await this.abstraction.getHardwareVersion().catch(console.error)) ?? undefined;
+    this.info.softwareVersion = (await this.abstraction.getSoftwareVersion().catch(console.error)) ?? undefined;
+
+    this.emit("printerinfofetched", new PrinterInfoFetchedEvent(this.info));
+    return this.info;
+  }
+
+  /**
+   * Get the stored information about the printer.
+   */
+  public getPrinterInfo(): PrinterInfo {
+    return this.info;
+  }
+
+  /**
+   * Set interval for {@link startHeartbeat}.
+   *
+   * @param intervalMs Heartbeat interval, default is 1000ms
+   */
+  public setHeartbeatInterval(intervalMs: number): void {
+    this.heartbeatIntervalMs = intervalMs;
+  }
+
+  /**
+   * Starts the heartbeat timer, "heartbeat" is emitted after packet received.
+   *
+   * If you need to change interval, call {@link setHeartbeatInterval} before.
+   */
+  public startHeartbeat(): void {
+    this.heartbeatFails = 0;
+
+    this.stopHeartbeat();
+
+    this.heartbeatTimer = setInterval(() => {
+      this.abstraction
+        .heartbeat()
+        .then((data) => {
+          this.heartbeatFails = 0;
+          this.emit("heartbeat", new HeartbeatEvent(data));
+        })
+        .catch((e) => {
+          console.error(e);
+          this.heartbeatFails++;
+          this.emit("heartbeatfailed", new HeartbeatFailedEvent(this.heartbeatFails));
+        });
+    }, this.heartbeatIntervalMs);
+  }
+
+  /**
+   * Stops the heartbeat by clearing the interval timer.
+   */
+  public stopHeartbeat(): void {
+    clearInterval(this.heartbeatTimer);
+    this.heartbeatTimer = undefined;
+  }
+
+  /**
+   * Checks if the heartbeat timer has been started.
+   */
+  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.modelId === undefined) {
+      return undefined;
+    }
+    return getPrinterMetaById(this.info.modelId);
+  }
+
+  /**
+   * Determine print task version if any.
+   **/
+  public getPrintTaskType(): PrintTaskName | undefined {
+    const meta = this.getModelMetadata();
+
+    if (meta === undefined) {
+      return undefined;
+    }
+
+    return findPrintTask(meta.model, this.getPrinterInfo().protocolVersion);
+  }
+
+  /**
+   * Set the interval between packets in milliseconds.
+   */
+  public setPacketInterval(milliseconds: number) {
+    this.packetIntervalMs = milliseconds;
+  }
+
+  /**
+   * Enable some debug information logging.
+   */
+  public setDebug(value: boolean) {
+    this.debug = value;
+  }
+}
diff --git a/src/client/bluetooth_impl.ts b/src/client/bluetooth_impl.ts
index 0f411a0..a32f37f 100644
--- a/src/client/bluetooth_impl.ts
+++ b/src/client/bluetooth_impl.ts
@@ -65,10 +65,13 @@ export class NiimbotBluetoothClient extends NiimbotAbstractClient {
 
     channel.addEventListener("characteristicvaluechanged", (event: Event) => {
       const target = event.target as BluetoothRemoteGATTCharacteristic;
+
       const data = new Uint8Array(target.value!.buffer);
-      const packet = NiimbotPacket.fromBytes(data);
 
       this.emit("rawpacketreceived", new RawPacketReceivedEvent(data));
+
+      const packet = NiimbotPacket.fromBytes(data);
+
       this.emit("packetreceived", new PacketReceivedEvent(packet));
 
       if (!(packet.command in ResponseCommandId)) {
diff --git a/src/client/capacitor_ble_impl.ts b/src/client/capacitor_ble_impl.ts
new file mode 100644
index 0000000..03d15a3
--- /dev/null
+++ b/src/client/capacitor_ble_impl.ts
@@ -0,0 +1,162 @@
+import {
+  ConnectEvent,
+  DisconnectEvent,
+  PacketReceivedEvent,
+  RawPacketReceivedEvent,
+  RawPacketSentEvent,
+} from "../events";
+import { ConnectionInfo, NiimbotAbstractClient } from ".";
+import { NiimbotPacket } from "../packets/packet";
+import { ConnectResult } from "../packets";
+import { Utils } from "../utils";
+import { BleCharacteristic, BleClient, BleDevice, BleService } from "@capacitor-community/bluetooth-le";
+
+/**
+ * Uses [@capacitor-community/bluetooth-le](https://github.com/capacitor-community/bluetooth-le)
+ *
+ * @category Client
+ */
+export class NiimbotCapacitorBleClient extends NiimbotAbstractClient {
+  private deviceId?: string;
+  private serviceUUID?: string;
+  private characteristicUUID?: string;
+  private packetBuf = new Uint8Array();
+
+  public async connect(): Promise<ConnectionInfo> {
+    await this.disconnect();
+
+    await BleClient.initialize({ androidNeverForLocation: true });
+
+    const bluetoothEnabled = await BleClient.isEnabled();
+
+    if (!bluetoothEnabled) {
+      throw new Error("Bluetooth is not enabled");
+    }
+
+    const device: BleDevice = await BleClient.requestDevice();
+
+    await BleClient.connect(device.deviceId, () => this.onBleDisconnect());
+
+    const { service, characteristic } = await this.findSuitableCharacteristic(device.deviceId).finally(() =>
+      this.onBleDisconnect()
+    );
+
+    this.deviceId = device.deviceId;
+    this.serviceUUID = service;
+    this.characteristicUUID = characteristic;
+
+    if (this.debug) {
+      console.log("Suitable channel found:", { service, characteristic });
+    }
+
+    await BleClient.startNotifications(this.deviceId, this.serviceUUID, this.characteristicUUID, (value: DataView) => {
+      this.onBlePacketReceived(value);
+    });
+
+    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.emit("connect", new ConnectEvent(result));
+
+    return result;
+  }
+
+  private async findSuitableCharacteristic(devId: string): Promise<{ service: string; characteristic: string }> {
+    const services: BleService[] = await BleClient.getServices(devId);
+
+    for (const service of services) {
+      if (service.uuid.length < 5) {
+        continue;
+      }
+
+      const characteristics: BleCharacteristic[] = service.characteristics;
+
+      for (const ch of characteristics) {
+        if (ch.properties.notify && ch.properties.writeWithoutResponse) {
+          return {
+            characteristic: ch.uuid,
+            service: service.uuid,
+          };
+        }
+      }
+    }
+    throw new Error("Unable to find suitable channel characteristic");
+  }
+
+  private onBlePacketReceived(dv: DataView) {
+    if (dv.byteLength === 0) {
+      return;
+    }
+
+    this.packetBuf = Utils.u8ArrayAppend(this.packetBuf, new Uint8Array(dv.buffer));
+
+    try {
+      const packets: NiimbotPacket[] = NiimbotPacket.fromBytesMultiPacket(this.packetBuf);
+
+      if (packets.length > 0) {
+        this.emit("rawpacketreceived", new RawPacketReceivedEvent(this.packetBuf));
+
+        packets.forEach((p) => {
+          this.emit("packetreceived", new PacketReceivedEvent(p));
+        });
+
+        this.packetBuf = new Uint8Array();
+      }
+    } catch (_e) {
+      if (this.debug) {
+        console.info(`Incomplete packet, ignoring:${Utils.bufToHex(this.packetBuf)}`);
+      }
+    }
+  }
+
+  private onBleDisconnect() {
+    this.deviceId = undefined;
+    this.serviceUUID = undefined;
+    this.characteristicUUID = undefined;
+    this.info = {};
+    this.emit("disconnect", new DisconnectEvent());
+  }
+
+  public isConnected(): boolean {
+    return this.deviceId !== undefined;
+  }
+
+  public async disconnect() {
+    this.stopHeartbeat();
+    if (this.deviceId !== undefined) {
+      await BleClient.stopNotifications(this.deviceId, this.serviceUUID!, this.characteristicUUID!);
+      await BleClient.disconnect(this.deviceId);
+    }
+    this.deviceId = undefined;
+    this.info = {};
+  }
+
+  public async sendRaw(data: Uint8Array, force?: boolean) {
+    const send = async () => {
+      if (!this.isConnected()) {
+        throw new Error("Channel is closed");
+      }
+      await Utils.sleep(this.packetIntervalMs);
+
+      const dw = new DataView(data.buffer, data.byteOffset, data.byteLength);
+      await BleClient.write(this.deviceId!, this.serviceUUID!, this.characteristicUUID!, dw);
+
+      this.emit("rawpacketsent", new RawPacketSentEvent(data));
+    };
+    if (force) {
+      await send();
+    } else {
+      await this.mutex.runExclusive(send);
+    }
+  }
+}
diff --git a/src/client/index.ts b/src/client/index.ts
index 57b2f8a..13910f1 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -1,290 +1,28 @@
-import { EventEmitter } from "eventemitter3";
-import { Mutex } from "async-mutex";
-import {
-  Abstraction,
-  AutoShutdownTime,
-  BatteryChargeLevel,
-  ConnectResult,
-  LabelType,
-  NiimbotPacket,
-  PrinterErrorCode,
-  PrintError,
-  ResponseCommandId,
-} from "../packets";
-import { PrinterModelMeta, getPrinterMetaById } from "../printer_models";
-import {
-  ClientEventMap,
-  PacketSentEvent,
-  PrinterInfoFetchedEvent,
-  HeartbeatEvent,
-  HeartbeatFailedEvent,
-  PacketReceivedEvent,
-} from "../events";
-import { findPrintTask, PrintTaskName } from "../print_tasks";
-import { Utils, Validators } from "../utils";
+import { NiimbotAbstractClient, ConnectionInfo, PrinterInfo } from "./abstract_client";
+import { NiimbotBluetoothClient } from "./bluetooth_impl";
+import { NiimbotCapacitorBleClient } from "./capacitor_ble_impl";
+import { NiimbotSerialClient } from "./serial_impl";
 
-/**
- * Represents the connection result information.
- *
- * @category Client
- */
-export type ConnectionInfo = {
-  deviceName?: string;
-  result: ConnectResult;
+/** Client type for {@link instantiateClient} */
+export type NiimbotClientType = "bluetooth" | "serial" | "capacitor-ble";
+
+/** Create new client instance */
+export const instantiateClient = (t: NiimbotClientType): NiimbotAbstractClient => {
+  if (t === "bluetooth") {
+    return new NiimbotBluetoothClient();
+  } else if (t === "capacitor-ble") {
+    return new NiimbotCapacitorBleClient();
+  } else if (t === "serial") {
+    return new NiimbotSerialClient();
+  }
+  throw new Error("Invalid client type");
 };
 
-/**
- * Interface representing printer information.
- *
- * @category Client
- */
-export interface PrinterInfo {
-  connectResult?: ConnectResult;
-  protocolVersion?: number;
-  modelId?: number;
-  serial?: string;
-  mac?: string;
-  charge?: BatteryChargeLevel;
-  autoShutdownTime?: AutoShutdownTime;
-  labelType?: LabelType;
-  softwareVersion?: string;
-  hardwareVersion?: string;
-}
-
-/**
- * Abstract class representing a client with common functionality for interacting with a printer.
- * Hardware interface must be defined after extending this class.
- *
- * @category Client
- */
-export abstract class NiimbotAbstractClient extends EventEmitter<ClientEventMap> {
-  public readonly abstraction: Abstraction;
-  protected info: PrinterInfo = {};
-  private heartbeatTimer?: NodeJS.Timeout;
-  private heartbeatFails: number = 0;
-  private heartbeatIntervalMs: number = 2_000;
-  protected mutex: Mutex = new Mutex();
-  protected debug: boolean = false;
-
-  /** @see https://github.com/MultiMote/niimblue/issues/5 */
-  protected packetIntervalMs: number = 10;
-
-  constructor() {
-    super();
-    this.abstraction = new Abstraction(this);
-    this.on("connect", () => this.startHeartbeat());
-    this.on("disconnect", () => this.stopHeartbeat());
-  }
-
-  /**
-   * Connect to printer port.
-   **/
-  public abstract connect(): Promise<ConnectionInfo>;
-
-  /**
-   * Disconnect from printer port.
-   **/
-  public abstract disconnect(): Promise<void>;
-
-  /**
-   * Check if the client is connected.
-   */
-  public abstract isConnected(): boolean;
-
-  /**
-   * Send packet and wait for response for {@link timeoutMs} milliseconds.
-   *
-   * If {@link NiimbotPacket.validResponseIds() validResponseIds} is defined, it will wait for packet with this command id.
-   *
-   * @throws {@link PrintError} when {@link ResponseCommandId.In_PrintError} or {@link ResponseCommandId.In_NotSupported} received.
-   *
-   * @returns {NiimbotPacket} Printer response object.
-   */
-  public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs: number = 1000): Promise<NiimbotPacket> {
-    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) => {
-          const pktIn = evt.packet;
-          const cmdIn = pktIn.command as ResponseCommandId;
-
-          if (
-            packet.validResponseIds.length === 0 ||
-            packet.validResponseIds.includes(cmdIn) ||
-            [ResponseCommandId.In_PrintError, ResponseCommandId.In_NotSupported].includes(cmdIn)
-          ) {
-            clearTimeout(timeout);
-            this.off("packetreceived", listener);
-
-            if (cmdIn === ResponseCommandId.In_PrintError) {
-              Validators.u8ArrayLengthEquals(pktIn.data, 1);
-              const errorName = PrinterErrorCode[pktIn.data[0]] ?? "unknown";
-              reject(new PrintError(`Print error ${pktIn.data[0]}: ${errorName}`, pktIn.data[0]));
-            } else if (cmdIn === ResponseCommandId.In_NotSupported) {
-              reject(new PrintError("Feature not supported", 0));
-            } else {
-              resolve(pktIn);
-            }
-          }
-        };
-
-        timeout = setTimeout(() => {
-          this.off("packetreceived", listener);
-          reject(new Error(`Timeout waiting response (waited for ${Utils.bufToHex(packet.validResponseIds, ", ")})`));
-        }, timeoutMs ?? 1000);
-
-        this.on("packetreceived", listener);
-      });
-    });
-  }
-
-  /**
-   * Send raw bytes to the printer port.
-   *
-   * @param data Bytes to send.
-   * @param force Ignore mutex lock. It used internally and 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.emit("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;
-    }
-  }
-
-  /**
-   * Fetches printer information and stores it.
-   */
-  public async fetchPrinterInfo(): Promise<PrinterInfo> {
-    this.info.modelId = await this.abstraction.getPrinterModel();
-
-    this.info.serial = (await this.abstraction.getPrinterSerialNumber().catch(console.error)) ?? undefined;
-    this.info.mac = (await this.abstraction.getPrinterBluetoothMacAddress().catch(console.error)) ?? undefined;
-    this.info.charge = (await this.abstraction.getBatteryChargeLevel().catch(console.error)) ?? undefined;
-    this.info.autoShutdownTime = (await this.abstraction.getAutoShutDownTime().catch(console.error)) ?? undefined;
-    this.info.labelType = (await this.abstraction.getLabelType().catch(console.error)) ?? undefined;
-    this.info.hardwareVersion = (await this.abstraction.getHardwareVersion().catch(console.error)) ?? undefined;
-    this.info.softwareVersion = (await this.abstraction.getSoftwareVersion().catch(console.error)) ?? undefined;
-
-    this.emit("printerinfofetched", new PrinterInfoFetchedEvent(this.info));
-    return this.info;
-  }
-
-  /**
-   * Get the stored information about the printer.
-   */
-  public getPrinterInfo(): PrinterInfo {
-    return this.info;
-  }
-
-  /**
-   * Set interval for {@link startHeartbeat}.
-   *
-   * @param intervalMs Heartbeat interval, default is 1000ms
-   */
-  public setHeartbeatInterval(intervalMs: number): void {
-    this.heartbeatIntervalMs = intervalMs;
-  }
-
-  /**
-   * Starts the heartbeat timer, "heartbeat" is emitted after packet received.
-   *
-   * If you need to change interval, call {@link setHeartbeatInterval} before.
-   */
-  public startHeartbeat(): void {
-    this.heartbeatFails = 0;
-
-    this.stopHeartbeat();
-
-    this.heartbeatTimer = setInterval(() => {
-      this.abstraction
-        .heartbeat()
-        .then((data) => {
-          this.heartbeatFails = 0;
-          this.emit("heartbeat", new HeartbeatEvent(data));
-        })
-        .catch((e) => {
-          console.error(e);
-          this.heartbeatFails++;
-          this.emit("heartbeatfailed", new HeartbeatFailedEvent(this.heartbeatFails));
-        });
-    }, this.heartbeatIntervalMs);
-  }
-
-  /**
-   * Stops the heartbeat by clearing the interval timer.
-   */
-  public stopHeartbeat(): void {
-    clearInterval(this.heartbeatTimer);
-    this.heartbeatTimer = undefined;
-  }
-
-  /**
-   * Checks if the heartbeat timer has been started.
-   */
-  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.modelId === undefined) {
-      return undefined;
-    }
-    return getPrinterMetaById(this.info.modelId);
-  }
-
-  /**
-   * Determine print task version if any.
-   **/
-  public getPrintTaskType(): PrintTaskName | undefined {
-    const meta = this.getModelMetadata();
-
-    if (meta === undefined) {
-      return undefined;
-    }
-
-    return findPrintTask(meta.model, this.getPrinterInfo().protocolVersion);
-  }
-
-  /**
-   * Set the interval between packets in milliseconds.
-   */
-  public setPacketInterval(milliseconds: number) {
-    this.packetIntervalMs = milliseconds;
-  }
-
-  /**
-   * Enable some debug information logging.
-   */
-  public setDebug(value: boolean) {
-    this.debug = value;
-  }
-}
-
-export * from "./bluetooth_impl";
-export * from "./serial_impl";
+export {
+  NiimbotAbstractClient,
+  ConnectionInfo,
+  PrinterInfo,
+  NiimbotBluetoothClient,
+  NiimbotCapacitorBleClient,
+  NiimbotSerialClient,
+};
diff --git a/src/client/serial_impl.ts b/src/client/serial_impl.ts
index 192c39f..812e042 100644
--- a/src/client/serial_impl.ts
+++ b/src/client/serial_impl.ts
@@ -73,10 +73,7 @@ export class NiimbotSerialClient extends NiimbotAbstractClient {
             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;
+          buf = Utils.u8ArrayAppend(buf, result.value);
         }
 
         if (result.done) {
diff --git a/src/utils.ts b/src/utils.ts
index 5b4962c..5945619 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,11 @@
+import { Capacitor } from "@capacitor/core";
+
+export interface AvailableTransports {
+  webSerial: boolean;
+  webBluetooth: boolean;
+  capacitorBle: boolean;
+}
+
 /**
  * Utility class for various common operations.
  * @category Helpers
@@ -126,6 +134,13 @@ export class Utils {
     return a.length === b.length && a.every((el, i) => el === b[i]);
   }
 
+  public static u8ArrayAppend(src: Uint8Array, data: Uint8Array): Uint8Array {
+    const newBuf = new Uint8Array(src.length + data.length);
+    newBuf.set(src, 0);
+    newBuf.set(data, src.length);
+    return newBuf;
+  }
+
   /**
    * Asynchronously pauses the execution for the specified amount of time.
    */
@@ -135,6 +150,7 @@ export class Utils {
 
   /**
    * Checks if the browser supports Bluetooth functionality.
+   * @deprecated use {@link getAvailableTransports}
    */
   public static isBluetoothSupported(): boolean {
     return typeof navigator.bluetooth?.requestDevice !== "undefined";
@@ -142,10 +158,22 @@ export class Utils {
 
   /**
    * Checks if the browser supports the Web Serial API for serial communication.
+   * @deprecated use {@link getAvailableTransports}
    */
   public static isSerialSupported(): boolean {
     return typeof navigator.serial?.requestPort !== "undefined";
   }
+
+  /**
+   * Checks environment functionality
+   */
+  public static getAvailableTransports(): AvailableTransports {
+    return {
+      capacitorBle: Capacitor.getPlatform() !== "web",
+      webBluetooth: typeof navigator.bluetooth?.requestDevice !== "undefined",
+      webSerial: typeof navigator.serial?.requestPort !== "undefined",
+    };
+  }
 }
 
 /**
diff --git a/yarn.lock b/yarn.lock
index c653b1d..4589997 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,6 +2,20 @@
 # yarn lockfile v1
 
 
+"@capacitor-community/bluetooth-le@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@capacitor-community/bluetooth-le/-/bluetooth-le-6.0.2.tgz#ba7bcb72126bd4cac4b70e79e8cfed24251b4f6c"
+  integrity sha512-X7ytHoHQzMTo89IR1UAu4JCmAJ3hraf+CPPLH/9zy9mUIlpZqcw0eN14vSMAtEBhb0vYz8qGQBQvN8FTRoz1Jw==
+  dependencies:
+    "@types/web-bluetooth" "^0.0.20"
+
+"@capacitor/core@^6.0.0":
+  version "6.1.2"
+  resolved "https://registry.yarnpkg.com/@capacitor/core/-/core-6.1.2.tgz#a072e78b46693a6a47047cfc90d5f7cc48299fe9"
+  integrity sha512-xFy1/4qLFLp5WCIzIhtwUuVNNoz36+V7/BzHmLqgVJcvotc4MMjswW/TshnPQaLLujEOaLkA4h8ZJ0uoK3ImGg==
+  dependencies:
+    tslib "^2.1.0"
+
 "@types/node@^20.14.2":
   version "20.14.2"
   resolved "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz"
@@ -31,6 +45,11 @@ eventemitter3@^5.0.1:
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
   integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
 
+tslib@^2.1.0:
+  version "2.8.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+  integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
 tslib@^2.4.0:
   version "2.6.3"
   resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz"