jc-printer-sdk-ts 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,11 @@ import type {
7
7
  PrintDocument,
8
8
  PrinterDevice,
9
9
  PrinterInfo,
10
+ PrinterReadiness,
11
+ PrinterReadinessWarningCode,
10
12
  PrinterSdkCallback,
11
13
  PrinterState,
14
+ PrinterStatusSnapshot,
12
15
  ScanCallback,
13
16
  } from "./types.js";
14
17
  import { JCPrintApi } from "./client.js";
@@ -33,6 +36,8 @@ export interface WebBluetoothPrinterTransportOptions {
33
36
  scanMode?: "all" | "name";
34
37
  optionalServices?: string[];
35
38
  chunkSize?: number;
39
+ preflightStatus?: boolean;
40
+ statusQueryTimeoutMs?: number;
36
41
  }
37
42
 
38
43
  interface ResolvedWebBluetoothPrinterTransportOptions {
@@ -42,6 +47,8 @@ interface ResolvedWebBluetoothPrinterTransportOptions {
42
47
  scanMode: "all" | "name";
43
48
  optionalServices: string[];
44
49
  chunkSize: number;
50
+ preflightStatus: boolean;
51
+ statusQueryTimeoutMs: number;
45
52
  }
46
53
 
47
54
  export const NIIMBOT_WEB_BLUETOOTH_SERVICE_UUID = "e7810a71-73ae-499d-8c15-faa9aef0c3f2";
@@ -54,6 +61,8 @@ export const DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS: ResolvedWebBluetoo
54
61
  scanMode: "all",
55
62
  optionalServices: [],
56
63
  chunkSize: 180,
64
+ preflightStatus: true,
65
+ statusQueryTimeoutMs: 700,
57
66
  };
58
67
 
59
68
  interface PrintJobContext {
@@ -90,6 +99,13 @@ interface PendingResponse {
90
99
  optional: boolean;
91
100
  }
92
101
 
102
+ const DEFAULT_GATT_BUSY_RETRY_COUNT = 8;
103
+ const DEFAULT_GATT_BUSY_RETRY_DELAY_MS = 25;
104
+ const HEARTBEAT_ADVANCED_1 = 0x01;
105
+ const HEARTBEAT_ADVANCED_2 = 0x04;
106
+ const HEARTBEAT_RESPONSE_COMMANDS = [0xd9, 0xdd, 0xde, 0xdf];
107
+ const INVERTED_LID_MODEL_IDS = new Set([512, 514, 513, 2304, 1792, 3584, 5120, 2560, 3840, 4352, 272, 273, 274]);
108
+
93
109
  function clone<T>(value: T): T {
94
110
  if (typeof globalThis.structuredClone === "function") {
95
111
  return globalThis.structuredClone(value);
@@ -105,6 +121,13 @@ function uniqueStrings(values: Array<string | undefined>): string[] {
105
121
  return Array.from(new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)));
106
122
  }
107
123
 
124
+ function isGattOperationBusyError(error: unknown): boolean {
125
+ if (!(error instanceof Error)) {
126
+ return false;
127
+ }
128
+ return /GATT operation already in progress/i.test(error.message);
129
+ }
130
+
108
131
  function resolveOptions(
109
132
  options: Partial<WebBluetoothPrinterTransportOptions> = {},
110
133
  ): ResolvedWebBluetoothPrinterTransportOptions {
@@ -119,9 +142,24 @@ function resolveOptions(
119
142
  scanMode: options.scanMode ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.scanMode,
120
143
  optionalServices: [...(options.optionalServices ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.optionalServices)],
121
144
  chunkSize: Math.max(20, chunkSize),
145
+ preflightStatus: options.preflightStatus ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.preflightStatus,
146
+ statusQueryTimeoutMs: Math.max(
147
+ 100,
148
+ Math.trunc(Number(options.statusQueryTimeoutMs ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.statusQueryTimeoutMs)),
149
+ ),
122
150
  };
123
151
  }
124
152
 
153
+ function signedByte(value: number): number {
154
+ const byte = value & 0xff;
155
+ return byte > 0x7f ? byte - 0x100 : byte;
156
+ }
157
+
158
+ function signedWord(high: number, low: number): number {
159
+ const value = ((high & 0xff) << 8) | (low & 0xff);
160
+ return value > 0x7fff ? value - 0x10000 : value;
161
+ }
162
+
125
163
  function defaultPrinterInfo(): PrinterInfo {
126
164
  return {
127
165
  deviceType: 2,
@@ -175,6 +213,7 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
175
213
  private b1HandshakeDone = false;
176
214
  private responseQueue: ParsedResponse[] = [];
177
215
  private pendingResponse: PendingResponse | undefined;
216
+ private gattOperationQueue: Promise<void> = Promise.resolve();
178
217
 
179
218
  constructor(options: Partial<WebBluetoothPrinterTransportOptions> = {}) {
180
219
  this.options = resolveOptions(options);
@@ -230,13 +269,13 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
230
269
  };
231
270
 
232
271
  const device = await navigator.bluetooth.requestDevice(requestOptions);
233
- const server = await device.gatt?.connect();
272
+ const server = await this.runGattOperation(() => device.gatt!.connect());
234
273
  if (!server) {
235
274
  throw new Error("Failed to open the selected Bluetooth device.");
236
275
  }
237
276
 
238
- const service = await server.getPrimaryService(serviceUuid);
239
- const characteristic = await service.getCharacteristic(characteristicUuid);
277
+ const service = await this.runGattOperation(() => server.getPrimaryService(serviceUuid));
278
+ const characteristic = await this.runGattOperation(() => service.getCharacteristic(characteristicUuid));
240
279
 
241
280
  this.device = device;
242
281
  this.server = server;
@@ -255,7 +294,7 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
255
294
 
256
295
  device.addEventListener("gattserverdisconnected", this.handleDisconnect);
257
296
  characteristic.addEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
258
- await characteristic.startNotifications();
297
+ await this.runGattOperation(() => characteristic.startNotifications());
259
298
  await this.handshakeAndIdentify();
260
299
  this.sdkCallback?.onConnectSuccess(this.lastAddress, 20);
261
300
  return 0;
@@ -303,14 +342,33 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
303
342
  printMode: number,
304
343
  callback: PrintCallback,
305
344
  ): Promise<void> {
345
+ if (this.currentJob && (this.printerState === "Printing" || this.printerState === "Working")) {
346
+ throw new Error("A print job is already in progress on this Web Bluetooth printer.");
347
+ }
348
+
306
349
  this.currentJob = { density, paperType, printMode, callback };
307
- this.printerState = "Printing";
308
- callback.onProgress(0, 0, {
309
- phase: "start",
310
- density,
311
- paperType,
312
- printMode,
313
- });
350
+ this.printerState = "Working";
351
+
352
+ try {
353
+ if (this.options.preflightStatus) {
354
+ const readiness = await this.evaluatePrintReadiness(this.options.statusQueryTimeoutMs, true);
355
+ if (!readiness.canPrint) {
356
+ throw new Error(`Printer is not ready to print: ${readiness.reason}`);
357
+ }
358
+ }
359
+
360
+ this.printerState = "Printing";
361
+ callback.onProgress(0, 0, {
362
+ phase: "start",
363
+ density,
364
+ paperType,
365
+ printMode,
366
+ });
367
+ } catch (error) {
368
+ this.currentJob = undefined;
369
+ this.printerState = this.server?.connected ? "Connected" : "Disconnected";
370
+ throw error;
371
+ }
314
372
  }
315
373
 
316
374
  async commitData(jsonPages: string[], infoPages: string[]): Promise<void> {
@@ -379,13 +437,15 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
379
437
 
380
438
  this.reconnecting = true;
381
439
  try {
382
- const server = await this.device.gatt.connect();
440
+ const server = await this.runGattOperation(() => this.device!.gatt!.connect());
383
441
  this.server = server;
384
- const service = await server.getPrimaryService(normalizeUuid(this.options.serviceUuid));
385
- const characteristic = await service.getCharacteristic(normalizeUuid(this.options.characteristicUuid));
442
+ const service = await this.runGattOperation(() => server.getPrimaryService(normalizeUuid(this.options.serviceUuid)));
443
+ const characteristic = await this.runGattOperation(() =>
444
+ service.getCharacteristic(normalizeUuid(this.options.characteristicUuid)),
445
+ );
386
446
  this.characteristic = characteristic;
387
447
  characteristic.addEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
388
- await characteristic.startNotifications();
448
+ await this.runGattOperation(() => characteristic.startNotifications());
389
449
  await this.handshakeAndIdentify();
390
450
  this.printerState = "Connected";
391
451
  this.sdkCallback?.onConnectSuccess(this.lastAddress, 20);
@@ -409,7 +469,7 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
409
469
  if (this.characteristic) {
410
470
  this.characteristic.removeEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
411
471
  try {
412
- await this.characteristic.stopNotifications();
472
+ await this.runGattOperation(() => this.characteristic!.stopNotifications());
413
473
  } catch {
414
474
  // Some browsers reject when the device is already gone.
415
475
  }
@@ -466,6 +526,22 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
466
526
  return clone(this.printerInfo);
467
527
  }
468
528
 
529
+ async queryPrinterStatus(timeoutMs = this.options.statusQueryTimeoutMs): Promise<PrinterStatusSnapshot> {
530
+ if (!this.isDeviceConnected()) {
531
+ return this.createLocalStatusSnapshot(false);
532
+ }
533
+
534
+ if (this.currentJob && (this.printerState === "Printing" || this.printerState === "Working")) {
535
+ return this.createLocalStatusSnapshot(true);
536
+ }
537
+
538
+ return this.queryPhysicalPrinterStatus(timeoutMs);
539
+ }
540
+
541
+ async canStartPrintJob(timeoutMs = this.options.statusQueryTimeoutMs): Promise<PrinterReadiness> {
542
+ return this.evaluatePrintReadiness(timeoutMs, false);
543
+ }
544
+
469
545
  async getPrinterState(): Promise<PrinterState> {
470
546
  return this.printerState;
471
547
  }
@@ -549,6 +625,196 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
549
625
  this.currentJob?.callback?.onCancelJob(success);
550
626
  }
551
627
 
628
+ private isDeviceConnected(): boolean {
629
+ return !!this.server?.connected && !!this.characteristic;
630
+ }
631
+
632
+ private createLocalStatusSnapshot(connected = this.isDeviceConnected()): PrinterStatusSnapshot {
633
+ return {
634
+ connected,
635
+ state: this.printerState,
636
+ statusAvailable: false,
637
+ };
638
+ }
639
+
640
+ private async evaluatePrintReadiness(
641
+ timeoutMs: number,
642
+ ignoreCurrentJob: boolean,
643
+ ): Promise<PrinterReadiness> {
644
+ if (!this.isDeviceConnected()) {
645
+ return this.createReadiness("not_connected", "Printer is not connected.", false, this.createLocalStatusSnapshot(false));
646
+ }
647
+
648
+ if (!ignoreCurrentJob && this.currentJob && this.printerState === "Printing") {
649
+ return this.createReadiness(
650
+ "sdk_printing",
651
+ "A print job is already in progress on this Web Bluetooth printer.",
652
+ false,
653
+ this.createLocalStatusSnapshot(true),
654
+ );
655
+ }
656
+
657
+ if (!ignoreCurrentJob && this.currentJob && this.printerState === "Working") {
658
+ return this.createReadiness(
659
+ "printer_working",
660
+ "Printer is preparing another job.",
661
+ false,
662
+ this.createLocalStatusSnapshot(true),
663
+ );
664
+ }
665
+
666
+ const status = await this.queryPhysicalPrinterStatus(timeoutMs);
667
+ const warnings = this.collectReadinessWarnings(status);
668
+
669
+ if (!status.statusAvailable) {
670
+ return this.createReadiness(
671
+ "status_unavailable",
672
+ "Printer is connected, but live hardware status did not respond before the timeout.",
673
+ true,
674
+ status,
675
+ warnings,
676
+ );
677
+ }
678
+
679
+ if (status.lidClosed === false) {
680
+ return this.createReadiness("cover_open", "Printer cover is open.", false, status, warnings);
681
+ }
682
+
683
+ if (status.paperInserted === false) {
684
+ return this.createReadiness("paper_missing", "Printer has no paper inserted.", false, status, warnings);
685
+ }
686
+
687
+ if (status.voltageState !== undefined && status.voltageState !== 0) {
688
+ return this.createReadiness("voltage_error", "Printer voltage state is abnormal.", false, status, warnings);
689
+ }
690
+
691
+ return this.createReadiness("ready", "Printer is ready to receive a print job.", true, status, warnings);
692
+ }
693
+
694
+ private createReadiness(
695
+ code: PrinterReadiness["code"],
696
+ reason: string,
697
+ canPrint: boolean,
698
+ status: PrinterStatusSnapshot,
699
+ warnings: PrinterReadinessWarningCode[] = [],
700
+ ): PrinterReadiness {
701
+ return {
702
+ canPrint,
703
+ code,
704
+ reason,
705
+ warnings,
706
+ state: status.state,
707
+ connected: status.connected,
708
+ status,
709
+ };
710
+ }
711
+
712
+ private collectReadinessWarnings(status: PrinterStatusSnapshot): PrinterReadinessWarningCode[] {
713
+ const warnings: PrinterReadinessWarningCode[] = [];
714
+ if (status.paperRfidSuccess === false) {
715
+ warnings.push("paper_rfid_failed");
716
+ }
717
+ if (status.ribbonInserted === false) {
718
+ warnings.push("ribbon_missing");
719
+ }
720
+ if (status.ribbonRfidSuccess === false) {
721
+ warnings.push("ribbon_rfid_failed");
722
+ }
723
+ if (status.lightingErrorCode !== undefined && status.lightingErrorCode !== 0) {
724
+ warnings.push("lighting_error");
725
+ }
726
+ return warnings;
727
+ }
728
+
729
+ private async queryPhysicalPrinterStatus(timeoutMs: number): Promise<PrinterStatusSnapshot> {
730
+ if (!this.isDeviceConnected()) {
731
+ return this.createLocalStatusSnapshot(false);
732
+ }
733
+
734
+ const heartbeatType = this.connectedProfile?.protocolVersion !== undefined && this.connectedProfile.protocolVersion < 3
735
+ ? HEARTBEAT_ADVANCED_1
736
+ : HEARTBEAT_ADVANCED_2;
737
+ let response = await this.sendCommand(0xdc, [heartbeatType], HEARTBEAT_RESPONSE_COMMANDS, timeoutMs, true);
738
+ if (!response && heartbeatType !== HEARTBEAT_ADVANCED_1) {
739
+ response = await this.sendCommand(0xdc, [HEARTBEAT_ADVANCED_1], HEARTBEAT_RESPONSE_COMMANDS, timeoutMs, true);
740
+ }
741
+
742
+ if (!response) {
743
+ return this.createLocalStatusSnapshot(true);
744
+ }
745
+
746
+ return this.parseHeartbeatStatus(response);
747
+ }
748
+
749
+ private parseHeartbeatStatus(response: ParsedResponse): PrinterStatusSnapshot {
750
+ const status: PrinterStatusSnapshot = {
751
+ connected: this.isDeviceConnected(),
752
+ state: this.printerState,
753
+ statusAvailable: false,
754
+ responseCommand: response.cmd,
755
+ rawData: [...response.data],
756
+ };
757
+
758
+ if (response.cmd === 0xd9) {
759
+ this.applyAdvanced2Heartbeat(status, response.data);
760
+ } else if (response.cmd === 0xdd) {
761
+ this.applyAdvanced1Heartbeat(status, response.data);
762
+ }
763
+
764
+ return status;
765
+ }
766
+
767
+ private applyAdvanced2Heartbeat(status: PrinterStatusSnapshot, data: number[]): void {
768
+ if (data.length < 9) {
769
+ return;
770
+ }
771
+
772
+ status.chargeLevel = signedByte(data[2]);
773
+ status.temperature = signedByte(data[3]);
774
+ status.lidClosed = signedByte(data[4]) === 0;
775
+ status.paperInserted = signedByte(data[5]) === 0;
776
+ status.paperRfidSuccess = signedByte(data[6]) !== 0;
777
+ status.ribbonRfidSuccess = signedByte(data[7]) !== 0;
778
+ status.ribbonInserted = signedByte(data[8]) === 0;
779
+ if (data.length >= 11) {
780
+ status.wifiRssi = signedWord(data[9], data[10]);
781
+ }
782
+ if (data.length >= 13) {
783
+ status.lightingErrorCode = signedByte(data[12]);
784
+ }
785
+ if (data.length >= 14) {
786
+ status.voltageState = signedByte(data[13]);
787
+ }
788
+ status.statusAvailable = true;
789
+ }
790
+
791
+ private applyAdvanced1Heartbeat(status: PrinterStatusSnapshot, data: number[]): void {
792
+ if (data.length === 10) {
793
+ status.lidClosed = signedByte(data[8]) === 0;
794
+ status.chargeLevel = signedByte(data[9]);
795
+ } else if (data.length === 13) {
796
+ status.lidClosed = signedByte(data[9]) === 0;
797
+ status.chargeLevel = signedByte(data[10]);
798
+ status.paperInserted = signedByte(data[11]) === 0;
799
+ status.paperRfidSuccess = signedByte(data[12]) !== 0;
800
+ } else if (data.length === 19) {
801
+ status.lidClosed = signedByte(data[15]) === 0;
802
+ status.chargeLevel = signedByte(data[16]);
803
+ status.paperInserted = signedByte(data[17]) === 0;
804
+ status.paperRfidSuccess = signedByte(data[18]) !== 0;
805
+ } else if (data.length === 20) {
806
+ status.paperInserted = signedByte(data[18]) === 0;
807
+ status.paperRfidSuccess = signedByte(data[19]) !== 0;
808
+ } else {
809
+ return;
810
+ }
811
+
812
+ if (status.lidClosed !== undefined && INVERTED_LID_MODEL_IDS.has(this.printerInfo.deviceType)) {
813
+ status.lidClosed = !status.lidClosed;
814
+ }
815
+ status.statusAvailable = true;
816
+ }
817
+
552
818
  private handleDisconnect = (): void => {
553
819
  this.printerState = "Disconnected";
554
820
  this.server = undefined;
@@ -787,9 +1053,8 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
787
1053
  timeoutMs = 2000,
788
1054
  optional = false,
789
1055
  ): Promise<ParsedResponse | null> {
790
- const wait = expected?.length ? this.waitForCommand(expected, timeoutMs, optional) : Promise.resolve(null);
791
1056
  await this.writeBytes(createFrame(cmd, data));
792
- return wait;
1057
+ return expected?.length ? this.waitForCommand(expected, timeoutMs, optional) : null;
793
1058
  }
794
1059
 
795
1060
  private async waitForCommand(
@@ -835,17 +1100,47 @@ export class WebBluetoothPrinterTransport implements PrinterTransport {
835
1100
  const payload = new ArrayBuffer(bytes.byteLength);
836
1101
  new Uint8Array(payload).set(bytes);
837
1102
 
838
- if (canWriteWithoutResponse) {
839
- await characteristic.writeValueWithoutResponse(payload);
840
- } else {
841
- await characteristic.writeValue(payload);
842
- }
1103
+ await this.runGattOperation(async () => {
1104
+ if (canWriteWithoutResponse) {
1105
+ await characteristic.writeValueWithoutResponse(payload);
1106
+ } else {
1107
+ await characteristic.writeValue(payload);
1108
+ }
1109
+ });
843
1110
 
844
1111
  if (paceMs > 0) {
845
1112
  await delay(paceMs);
846
1113
  }
847
1114
  }
848
1115
 
1116
+ private async runGattOperation<T>(operation: () => Promise<T>): Promise<T> {
1117
+ const run = async (): Promise<T> => {
1118
+ let lastError: unknown;
1119
+
1120
+ for (let attempt = 0; attempt <= DEFAULT_GATT_BUSY_RETRY_COUNT; attempt += 1) {
1121
+ try {
1122
+ return await operation();
1123
+ } catch (error) {
1124
+ lastError = error;
1125
+ if (!isGattOperationBusyError(error) || attempt >= DEFAULT_GATT_BUSY_RETRY_COUNT) {
1126
+ throw error;
1127
+ }
1128
+
1129
+ await delay(DEFAULT_GATT_BUSY_RETRY_DELAY_MS * (attempt + 1));
1130
+ }
1131
+ }
1132
+
1133
+ throw lastError;
1134
+ };
1135
+
1136
+ const queued = this.gattOperationQueue.then(run, run);
1137
+ this.gattOperationQueue = queued.then(
1138
+ () => undefined,
1139
+ () => undefined,
1140
+ );
1141
+ return queued;
1142
+ }
1143
+
849
1144
  private normalizeDensity(value: number, task: "v4" | "b1"): number {
850
1145
  if (task === "b1") {
851
1146
  return Math.max(1, Math.min(5, Math.round(value)));