Skip to content

Latest commit

Β 

History

History
260 lines (216 loc) Β· 10.4 KB

File metadata and controls

260 lines (216 loc) Β· 10.4 KB

BLE Transport

The BleTransport interface

The library communicates with printers through this interface. You implement it once for your BLE stack, then pass it to discover() and Printer.connect().

interface BleTransport {
  scan(
    onDiscover: (peripheral: BlePeripheral) => void,
    options?: ScanOptions,
  ): Promise<ScanHandle>;
  connect(peripheral: BlePeripheral): Promise<BleConnection>;
}

interface BlePeripheral {
  id: string;       // Platform-specific identifier (MAC, UUID, etc.)
  name: string;     // Advertised local name β€” used for device matching
  rssi: number;
}

interface ScanOptions {
  timeoutMs?: number;
  namePrefix?: string;
}

interface ScanHandle {
  stop(): Promise<void>;
}

interface BleConnection {
  discoverService(uuid: string): Promise<BleService | null>;
  disconnect(): Promise<void>;
  readonly isConnected: boolean;
}

interface BleService {
  getCharacteristic(uuid: string): Promise<BleCharacteristic | null>;
}

interface BleCharacteristic {
  write(data: Uint8Array, withoutResponse: boolean): Promise<void>;
  subscribe(listener: (data: Uint8Array) => void): Promise<void>;
  unsubscribe(): Promise<void>;
}

Implementing a transport adapter

Step by step:

  1. scan() β€” Start a BLE scan. Call onDiscover for each peripheral found. Return a ScanHandle whose stop() halts scanning. The library calls stop() when it finds a match or times out.

  2. connect() β€” Establish a BLE connection to the given peripheral. Return a BleConnection.

  3. discoverService(uuid) β€” After connecting, the library calls this with the printer's service UUID (e.g. 0000ff00-0000-1000-8000-00805f9b34fb). Discover GATT services and return the matching one, or null.

  4. getCharacteristic(uuid) β€” Return the characteristic for the given UUID, or null. The library uses three characteristics:

    • TX (ff02) β€” write commands to the printer
    • RX (ff01) β€” subscribe for responses (status, print success)
    • CX (ff03) β€” subscribe for flow control credits
  5. write(data, withoutResponse) β€” Write bytes to the characteristic. The library always passes withoutResponse = true for TX.

  6. subscribe(listener) β€” Register a notification listener. The library subscribes to RX and CX.

  7. unsubscribe() β€” Remove the notification listener.

Example: Noble adapter

import noble from "@abandonware/noble";
import type {
  BleTransport,
  BleConnection,
  BleService,
  BleCharacteristic,
  BlePeripheral,
  ScanHandle,
  ScanOptions,
} from "@thermoprint/core";

export function createNobleTransport(): BleTransport {
  return {
    async scan(onDiscover, options?: ScanOptions): Promise<ScanHandle> {
      const uuids = options?.namePrefix ? [] : [];
      noble.on("discover", (p) => {
        const name = p.advertisement.localName ?? "";
        if (options?.namePrefix && !name.startsWith(options.namePrefix)) return;
        onDiscover({ id: p.uuid, name, rssi: p.rssi });
      });
      await noble.startScanningAsync(uuids, true);
      return {
        async stop() {
          await noble.stopScanningAsync();
          noble.removeAllListeners("discover");
        },
      };
    },

    async connect(peripheral: BlePeripheral): Promise<BleConnection> {
      // Re-discover the noble peripheral by ID
      const noblePeripheral = noble._peripherals[peripheral.id];
      await noblePeripheral.connectAsync();
      const { services } = await noblePeripheral.discoverAllServicesAndCharacteristicsAsync();

      return {
        get isConnected() { return noblePeripheral.state === "connected"; },
        async disconnect() { await noblePeripheral.disconnectAsync(); },
        async discoverService(uuid: string): Promise<BleService | null> {
          const svc = services.find((s) => s.uuid === uuid.replace(/-/g, ""));
          if (!svc) return null;
          return {
            async getCharacteristic(charUuid: string): Promise<BleCharacteristic | null> {
              const ch = svc.characteristics.find(
                (c) => c.uuid === charUuid.replace(/-/g, ""),
              );
              if (!ch) return null;
              return {
                async write(data, withoutResponse) {
                  await ch.writeAsync(Buffer.from(data), withoutResponse);
                },
                async subscribe(listener) {
                  ch.on("data", (buf: Buffer) => listener(new Uint8Array(buf)));
                  await ch.subscribeAsync();
                },
                async unsubscribe() {
                  ch.removeAllListeners("data");
                  await ch.unsubscribeAsync();
                },
              };
            },
          };
        },
      };
    },
  };
}

Example: Web Bluetooth adapter

import type {
  BleTransport,
  BleConnection,
  BleService,
  BleCharacteristic,
  BlePeripheral,
  ScanHandle,
  ScanOptions,
} from "@thermoprint/core";

export function createWebBluetoothTransport(): BleTransport {
  return {
    async scan(onDiscover, options?: ScanOptions): Promise<ScanHandle> {
      // Web Bluetooth uses a picker dialog β€” no continuous scan.
      // requestDevice returns a single device chosen by the user.
      const device = await navigator.bluetooth.requestDevice({
        filters: [{ services: [0xff00] }],
        optionalServices: [0xff00],
      });
      onDiscover({
        id: device.id,
        name: device.name ?? "Unknown",
        rssi: 0,
      });
      // Store device for connect() to retrieve
      (this as any)._device = device;
      return { async stop() {} };
    },

    async connect(peripheral: BlePeripheral): Promise<BleConnection> {
      const device: BluetoothDevice = (this as any)._device;
      const server = await device.gatt!.connect();

      return {
        get isConnected() { return server.connected; },
        async disconnect() { server.disconnect(); },
        async discoverService(uuid: string): Promise<BleService | null> {
          try {
            const svc = await server.getPrimaryService(uuid);
            return {
              async getCharacteristic(charUuid: string): Promise<BleCharacteristic | null> {
                try {
                  const ch = await svc.getCharacteristic(charUuid);
                  return {
                    async write(data, withoutResponse) {
                      if (withoutResponse) {
                        await ch.writeValueWithoutResponse(data);
                      } else {
                        await ch.writeValueWithResponse(data);
                      }
                    },
                    async subscribe(listener) {
                      ch.addEventListener("characteristicvaluechanged", (e) => {
                        const value = (e.target as BluetoothRemoteGATTCharacteristic).value!;
                        listener(new Uint8Array(value.buffer));
                      });
                      await ch.startNotifications();
                    },
                    async unsubscribe() {
                      await ch.stopNotifications();
                    },
                  };
                } catch { return null; }
              },
            };
          } catch { return null; }
        },
      };
    },
  };
}

Flow control

Thermoprint uses credit-based flow control to avoid overwhelming the printer's BLE buffer. This is handled automatically by FlowController β€” you don't need to implement it in your transport.

How it works

  1. Credits start at zero. The FlowController starts with no credits. The printer grants the initial credits (typically 4) via a CX notification after connection. Each credit allows sending one packet.

  2. Timer-based sending. Data is sent on a periodic timer matching the official Marklife app's approach. The timer interval is packetDelayMs (P15: 30ms, default: 30ms). On each tick, at most one packet is sent if credits are available.

  3. Packet chunking. Data is split into chunks of packetSize bytes (P15: 95 bytes, P12: 90 bytes, fallback: 237 = default MTU). Each chunk consumes one credit.

  4. Credit grants. The printer sends credit notifications on the CX characteristic. The protocol parses [0x01, count] as a credit grant. FlowController.grantCredits(count) adds them.

  5. Starvation recovery. If no credit arrives within starvationTimeoutMs (default: 1000ms), the controller unconditionally forces credits = 1 and continues. This matches the official app's recovery logic and prevents deadlocks caused by lost BLE notifications.

Sequence diagram

Printer                           FlowController
  β”‚                                    β”‚
  β”‚                               send(data)
  β”‚                                    │── chunk 1 β†’ write(chunk, true)
  β”‚                                    │── chunk 2 β†’ write(chunk, true)
  β”‚                                    │── chunk 3 β†’ write(chunk, true)
  β”‚                                    │── chunk 4 β†’ write(chunk, true)
  β”‚                                    β”‚   credits = 0, waiting...
  β”‚  ◄── CX notify [0x01, 0x04] ──────│
  β”‚                                    β”‚   grantCredits(4)
  β”‚                                    │── chunk 5 β†’ write(chunk, true)
  β”‚                                    β”‚   ...

Gotchas

  • Noble UUID format. Noble strips hyphens from UUIDs (e.g., 0000ff0000001000800000805f9b34fb). Your discoverService / getCharacteristic wrapper needs to normalize the format.

  • Web Bluetooth picker. Web Bluetooth doesn't support continuous scanning. scan() triggers a user-facing dialog that returns one device. Implement accordingly β€” discoverAll() will only return that single device.

  • withoutResponse must work. The library always writes with withoutResponse = true. If your BLE stack requires write-with-response for some characteristics, you may need to handle that in your adapter, but the current protocol only uses write-without-response on TX.

  • Notification subscriptions are mandatory. The library subscribes to RX (for status/success) and CX (for credits). If subscribe doesn't work, prints will hang waiting for credits or the success acknowledgment.

  • Thread safety. FlowController.send() is not re-entrant. The Printer class sends commands sequentially. If you bypass Printer and use FlowController directly, don't call send() concurrently.