mirror of
https://github.com/MultiMote/niimbluelib.git
synced 2025-03-16 03:21:01 +03:00
Capacitor BLE client implementation, instantiateClient function
This commit is contained in:
parent
8e15d2e7bf
commit
dad965933e
@ -39,6 +39,8 @@
|
|||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/bluetooth-le": "^6.0.2",
|
||||||
|
"@capacitor/core": "^6.0.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
},
|
},
|
||||||
|
287
src/client/abstract_client.ts
Normal file
287
src/client/abstract_client.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -65,10 +65,13 @@ export class NiimbotBluetoothClient extends NiimbotAbstractClient {
|
|||||||
|
|
||||||
channel.addEventListener("characteristicvaluechanged", (event: Event) => {
|
channel.addEventListener("characteristicvaluechanged", (event: Event) => {
|
||||||
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
||||||
|
|
||||||
const data = new Uint8Array(target.value!.buffer);
|
const data = new Uint8Array(target.value!.buffer);
|
||||||
const packet = NiimbotPacket.fromBytes(data);
|
|
||||||
|
|
||||||
this.emit("rawpacketreceived", new RawPacketReceivedEvent(data));
|
this.emit("rawpacketreceived", new RawPacketReceivedEvent(data));
|
||||||
|
|
||||||
|
const packet = NiimbotPacket.fromBytes(data);
|
||||||
|
|
||||||
this.emit("packetreceived", new PacketReceivedEvent(packet));
|
this.emit("packetreceived", new PacketReceivedEvent(packet));
|
||||||
|
|
||||||
if (!(packet.command in ResponseCommandId)) {
|
if (!(packet.command in ResponseCommandId)) {
|
||||||
|
162
src/client/capacitor_ble_impl.ts
Normal file
162
src/client/capacitor_ble_impl.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,290 +1,28 @@
|
|||||||
import { EventEmitter } from "eventemitter3";
|
import { NiimbotAbstractClient, ConnectionInfo, PrinterInfo } from "./abstract_client";
|
||||||
import { Mutex } from "async-mutex";
|
import { NiimbotBluetoothClient } from "./bluetooth_impl";
|
||||||
import {
|
import { NiimbotCapacitorBleClient } from "./capacitor_ble_impl";
|
||||||
Abstraction,
|
import { NiimbotSerialClient } from "./serial_impl";
|
||||||
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";
|
|
||||||
|
|
||||||
/**
|
/** Client type for {@link instantiateClient} */
|
||||||
* Represents the connection result information.
|
export type NiimbotClientType = "bluetooth" | "serial" | "capacitor-ble";
|
||||||
*
|
|
||||||
* @category Client
|
/** Create new client instance */
|
||||||
*/
|
export const instantiateClient = (t: NiimbotClientType): NiimbotAbstractClient => {
|
||||||
export type ConnectionInfo = {
|
if (t === "bluetooth") {
|
||||||
deviceName?: string;
|
return new NiimbotBluetoothClient();
|
||||||
result: ConnectResult;
|
} else if (t === "capacitor-ble") {
|
||||||
|
return new NiimbotCapacitorBleClient();
|
||||||
|
} else if (t === "serial") {
|
||||||
|
return new NiimbotSerialClient();
|
||||||
|
}
|
||||||
|
throw new Error("Invalid client type");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export {
|
||||||
* Interface representing printer information.
|
NiimbotAbstractClient,
|
||||||
*
|
ConnectionInfo,
|
||||||
* @category Client
|
PrinterInfo,
|
||||||
*/
|
NiimbotBluetoothClient,
|
||||||
export interface PrinterInfo {
|
NiimbotCapacitorBleClient,
|
||||||
connectResult?: ConnectResult;
|
NiimbotSerialClient,
|
||||||
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";
|
|
||||||
|
@ -73,10 +73,7 @@ export class NiimbotSerialClient extends NiimbotAbstractClient {
|
|||||||
console.info(`<< serial chunk ${Utils.bufToHex(result.value)}`);
|
console.info(`<< serial chunk ${Utils.bufToHex(result.value)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBuf = new Uint8Array(buf.length + result.value.length);
|
buf = Utils.u8ArrayAppend(buf, result.value);
|
||||||
newBuf.set(buf, 0);
|
|
||||||
newBuf.set(result.value, buf.length);
|
|
||||||
buf = newBuf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.done) {
|
if (result.done) {
|
||||||
|
28
src/utils.ts
28
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.
|
* Utility class for various common operations.
|
||||||
* @category Helpers
|
* @category Helpers
|
||||||
@ -126,6 +134,13 @@ export class Utils {
|
|||||||
return a.length === b.length && a.every((el, i) => el === b[i]);
|
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.
|
* Asynchronously pauses the execution for the specified amount of time.
|
||||||
*/
|
*/
|
||||||
@ -135,6 +150,7 @@ export class Utils {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the browser supports Bluetooth functionality.
|
* Checks if the browser supports Bluetooth functionality.
|
||||||
|
* @deprecated use {@link getAvailableTransports}
|
||||||
*/
|
*/
|
||||||
public static isBluetoothSupported(): boolean {
|
public static isBluetoothSupported(): boolean {
|
||||||
return typeof navigator.bluetooth?.requestDevice !== "undefined";
|
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.
|
* Checks if the browser supports the Web Serial API for serial communication.
|
||||||
|
* @deprecated use {@link getAvailableTransports}
|
||||||
*/
|
*/
|
||||||
public static isSerialSupported(): boolean {
|
public static isSerialSupported(): boolean {
|
||||||
return typeof navigator.serial?.requestPort !== "undefined";
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
19
yarn.lock
19
yarn.lock
@ -2,6 +2,20 @@
|
|||||||
# yarn lockfile v1
|
# 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":
|
"@types/node@^20.14.2":
|
||||||
version "20.14.2"
|
version "20.14.2"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz"
|
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"
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
|
||||||
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
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:
|
tslib@^2.4.0:
|
||||||
version "2.6.3"
|
version "2.6.3"
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user