jc-printer-sdk-ts 0.1.0 → 0.1.1

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.
@@ -0,0 +1,876 @@
1
+ import type {
2
+ BitmapLike,
3
+ BundleLike,
4
+ IAtBitmapDocument,
5
+ PaperInfo,
6
+ PrintCallback,
7
+ PrintDocument,
8
+ PrinterDevice,
9
+ PrinterInfo,
10
+ PrinterSdkCallback,
11
+ PrinterState,
12
+ ScanCallback,
13
+ } from "./types.js";
14
+ import { JCPrintApi } from "./client.js";
15
+ import type { PrinterTransport } from "./transport.js";
16
+ import {
17
+ createConnectFrame,
18
+ createFrame,
19
+ createRowFrames,
20
+ fallbackProfile,
21
+ identifyProfile,
22
+ parseFrame,
23
+ parseModelId,
24
+ parseProtocolVersion,
25
+ rasterizeDocumentPage,
26
+ type NiimbotPrinterProfile,
27
+ } from "./niimbotProtocol.js";
28
+
29
+ export interface WebBluetoothPrinterTransportOptions {
30
+ serviceUuid?: string;
31
+ characteristicUuid?: string;
32
+ deviceNameFilter?: string;
33
+ scanMode?: "all" | "name";
34
+ optionalServices?: string[];
35
+ chunkSize?: number;
36
+ }
37
+
38
+ interface ResolvedWebBluetoothPrinterTransportOptions {
39
+ serviceUuid: string;
40
+ characteristicUuid: string;
41
+ deviceNameFilter: string;
42
+ scanMode: "all" | "name";
43
+ optionalServices: string[];
44
+ chunkSize: number;
45
+ }
46
+
47
+ export const NIIMBOT_WEB_BLUETOOTH_SERVICE_UUID = "e7810a71-73ae-499d-8c15-faa9aef0c3f2";
48
+ export const NIIMBOT_WEB_BLUETOOTH_CHARACTERISTIC_UUID = "bef8d6c9-9c21-4c9e-b632-bd58c1009f9f";
49
+
50
+ export const DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS: ResolvedWebBluetoothPrinterTransportOptions = {
51
+ serviceUuid: NIIMBOT_WEB_BLUETOOTH_SERVICE_UUID,
52
+ characteristicUuid: NIIMBOT_WEB_BLUETOOTH_CHARACTERISTIC_UUID,
53
+ deviceNameFilter: "",
54
+ scanMode: "all",
55
+ optionalServices: [],
56
+ chunkSize: 180,
57
+ };
58
+
59
+ interface PrintJobContext {
60
+ density: number;
61
+ paperType: number;
62
+ printMode: number;
63
+ callback?: PrintCallback;
64
+ }
65
+
66
+ type PayloadEnvelope = {
67
+ kind: "jc-printer-sdk-ts/web-bluetooth";
68
+ sentAt: string;
69
+ job: {
70
+ density: number;
71
+ paperType: number;
72
+ printMode: number;
73
+ };
74
+ document?: PrintDocument;
75
+ jsonPages?: string[];
76
+ infoPages?: string[];
77
+ profile?: Record<string, unknown>;
78
+ };
79
+
80
+ interface ParsedResponse {
81
+ cmd: number;
82
+ data: number[];
83
+ }
84
+
85
+ interface PendingResponse {
86
+ expected: Set<number>;
87
+ resolve(response: ParsedResponse | null): void;
88
+ reject(error: Error): void;
89
+ timeoutId: number;
90
+ optional: boolean;
91
+ }
92
+
93
+ function clone<T>(value: T): T {
94
+ if (typeof globalThis.structuredClone === "function") {
95
+ return globalThis.structuredClone(value);
96
+ }
97
+ return JSON.parse(JSON.stringify(value)) as T;
98
+ }
99
+
100
+ function normalizeUuid(uuid: string): string {
101
+ return uuid.trim().toLowerCase();
102
+ }
103
+
104
+ function uniqueStrings(values: Array<string | undefined>): string[] {
105
+ return Array.from(new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)));
106
+ }
107
+
108
+ function resolveOptions(
109
+ options: Partial<WebBluetoothPrinterTransportOptions> = {},
110
+ ): ResolvedWebBluetoothPrinterTransportOptions {
111
+ const chunkSize = Number.isFinite(options.chunkSize)
112
+ ? Math.trunc(Number(options.chunkSize))
113
+ : DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.chunkSize;
114
+
115
+ return {
116
+ serviceUuid: options.serviceUuid ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.serviceUuid,
117
+ characteristicUuid: options.characteristicUuid ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.characteristicUuid,
118
+ deviceNameFilter: options.deviceNameFilter?.trim() ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.deviceNameFilter,
119
+ scanMode: options.scanMode ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.scanMode,
120
+ optionalServices: [...(options.optionalServices ?? DEFAULT_WEB_BLUETOOTH_PRINTER_TRANSPORT_OPTIONS.optionalServices)],
121
+ chunkSize: Math.max(20, chunkSize),
122
+ };
123
+ }
124
+
125
+ function defaultPrinterInfo(): PrinterInfo {
126
+ return {
127
+ deviceType: 2,
128
+ deviceName: "",
129
+ deviceVersion: "",
130
+ softwareVersion: "web-bluetooth",
131
+ deviceAddress: "",
132
+ deviceAddrType: 2,
133
+ deviceDPI: 0,
134
+ deviceWidth: 0,
135
+ manufacturer: "",
136
+ seriesName: "",
137
+ devIntName: "",
138
+ peripheralFlags: 0,
139
+ hardwareFlags: 0,
140
+ softwareFlags: 0,
141
+ };
142
+ }
143
+
144
+ function defaultPaperInfo(): PaperInfo {
145
+ return {
146
+ state: 0,
147
+ gapHeightPixel: 0,
148
+ totalHeightPixel: 0,
149
+ paperType: 0,
150
+ gapHeight: 0,
151
+ totalHeight: 0,
152
+ paperWidthPixel: 0,
153
+ paperWidth: 0,
154
+ direction: 0,
155
+ tailLengthPixel: 0,
156
+ tailLength: 0,
157
+ };
158
+ }
159
+
160
+ export class WebBluetoothPrinterTransport implements PrinterTransport {
161
+ private options: ResolvedWebBluetoothPrinterTransportOptions;
162
+ private sdkCallback: PrinterSdkCallback | undefined;
163
+ private device: BluetoothDevice | undefined;
164
+ private server: BluetoothRemoteGATTServer | undefined;
165
+ private characteristic: BluetoothRemoteGATTCharacteristic | undefined;
166
+ private printerName = "";
167
+ private printerInfo: PrinterInfo = defaultPrinterInfo();
168
+ private paperInfo: PaperInfo = defaultPaperInfo();
169
+ private printerState: PrinterState = "Disconnected";
170
+ private lastAddress = "";
171
+ private currentJob: PrintJobContext | undefined;
172
+ private lastPayload: PayloadEnvelope | undefined;
173
+ private reconnecting = false;
174
+ private connectedProfile: NiimbotPrinterProfile | null = null;
175
+ private b1HandshakeDone = false;
176
+ private responseQueue: ParsedResponse[] = [];
177
+ private pendingResponse: PendingResponse | undefined;
178
+
179
+ constructor(options: Partial<WebBluetoothPrinterTransportOptions> = {}) {
180
+ this.options = resolveOptions(options);
181
+ }
182
+
183
+ configure(options: Partial<WebBluetoothPrinterTransportOptions>): void {
184
+ this.options = resolveOptions({
185
+ ...this.options,
186
+ ...options,
187
+ });
188
+ }
189
+
190
+ getLastPayload(): PayloadEnvelope | undefined {
191
+ return this.lastPayload ? clone(this.lastPayload) : undefined;
192
+ }
193
+
194
+ setSdkCallback(callback?: PrinterSdkCallback): void {
195
+ this.sdkCallback = callback;
196
+ if (callback && this.printerState !== "Disconnected" && this.lastAddress) {
197
+ callback.onConnectSuccess(this.lastAddress, 20);
198
+ }
199
+ }
200
+
201
+ async initSdk(): Promise<boolean> {
202
+ return true;
203
+ }
204
+
205
+ async init(): Promise<boolean> {
206
+ return true;
207
+ }
208
+
209
+ async connectBluetoothPrinter(address: string): Promise<number> {
210
+ if (!navigator.bluetooth) {
211
+ throw new Error("Web Bluetooth is not available in this browser.");
212
+ }
213
+
214
+ const deviceFilter = this.options.deviceNameFilter?.trim() || address.trim();
215
+ const serviceUuid = normalizeUuid(this.options.serviceUuid);
216
+ const characteristicUuid = normalizeUuid(this.options.characteristicUuid);
217
+ if (!serviceUuid || !characteristicUuid) {
218
+ throw new Error("Service UUID and characteristic UUID are required for Web Bluetooth printing.");
219
+ }
220
+ const optionalServices = uniqueStrings([serviceUuid, ...(this.options.optionalServices ?? [])]);
221
+ const useNameFilter = this.options.scanMode === "name" && deviceFilter;
222
+ const requestOptions: BluetoothRequestDeviceOptions = useNameFilter
223
+ ? {
224
+ filters: [{ namePrefix: deviceFilter }],
225
+ optionalServices,
226
+ }
227
+ : {
228
+ acceptAllDevices: true,
229
+ optionalServices,
230
+ };
231
+
232
+ const device = await navigator.bluetooth.requestDevice(requestOptions);
233
+ const server = await device.gatt?.connect();
234
+ if (!server) {
235
+ throw new Error("Failed to open the selected Bluetooth device.");
236
+ }
237
+
238
+ const service = await server.getPrimaryService(serviceUuid);
239
+ const characteristic = await service.getCharacteristic(characteristicUuid);
240
+
241
+ this.device = device;
242
+ this.server = server;
243
+ this.characteristic = characteristic;
244
+ this.lastAddress = device.name || device.id || deviceFilter || serviceUuid;
245
+ this.printerName = this.lastAddress;
246
+ this.printerState = "Connected";
247
+ this.printerInfo = {
248
+ ...defaultPrinterInfo(),
249
+ deviceName: this.printerName,
250
+ deviceAddress: this.lastAddress,
251
+ softwareVersion: "web-bluetooth",
252
+ deviceAddrType: 2,
253
+ };
254
+ this.paperInfo = defaultPaperInfo();
255
+
256
+ device.addEventListener("gattserverdisconnected", this.handleDisconnect);
257
+ characteristic.addEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
258
+ await characteristic.startNotifications();
259
+ await this.handshakeAndIdentify();
260
+ this.sdkCallback?.onConnectSuccess(this.lastAddress, 20);
261
+ return 0;
262
+ }
263
+
264
+ async openPrinterByAddress(address: string): Promise<number> {
265
+ return this.connectBluetoothPrinter(address);
266
+ }
267
+
268
+ async openPrinterSync(address: string): Promise<number> {
269
+ return this.connectBluetoothPrinter(address);
270
+ }
271
+
272
+ async connectWifiPrinter(): Promise<number> {
273
+ throw new Error("Wi-Fi printing is not implemented in the browser transport.");
274
+ }
275
+
276
+ async configurationWifi(): Promise<number> {
277
+ return 0;
278
+ }
279
+
280
+ async getConfigurationWifiName(): Promise<string> {
281
+ return "";
282
+ }
283
+
284
+ async scanWifiPrinter(callback: ScanCallback): Promise<void> {
285
+ callback.onFinish();
286
+ }
287
+
288
+ async setPrinterAutoShutdownTime(): Promise<number> {
289
+ return 0;
290
+ }
291
+
292
+ async setTotalPrintQuantity(): Promise<void> {
293
+ return;
294
+ }
295
+
296
+ async setTotalQuantityOfPrints(): Promise<void> {
297
+ return;
298
+ }
299
+
300
+ async startPrintJob(
301
+ density: number,
302
+ paperType: number,
303
+ printMode: number,
304
+ callback: PrintCallback,
305
+ ): Promise<void> {
306
+ 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
+ });
314
+ }
315
+
316
+ async commitData(jsonPages: string[], infoPages: string[]): Promise<void> {
317
+ await this.writePayload({
318
+ kind: "jc-printer-sdk-ts/web-bluetooth",
319
+ sentAt: new Date().toISOString(),
320
+ job: {
321
+ density: this.currentJob?.density ?? 0,
322
+ paperType: this.currentJob?.paperType ?? 0,
323
+ printMode: this.currentJob?.printMode ?? 0,
324
+ },
325
+ jsonPages: clone(jsonPages),
326
+ infoPages: clone(infoPages),
327
+ });
328
+ }
329
+
330
+ async commitDocument(document: PrintDocument, infoPages: string[]): Promise<void> {
331
+ const profile = await this.resolveProfile(document);
332
+ this.lastPayload = {
333
+ kind: "jc-printer-sdk-ts/web-bluetooth",
334
+ sentAt: new Date().toISOString(),
335
+ job: {
336
+ density: document.job.density ?? this.currentJob?.density ?? 0,
337
+ paperType: document.job.paperType ?? this.currentJob?.paperType ?? 0,
338
+ printMode: document.job.printMode ?? this.currentJob?.printMode ?? 0,
339
+ },
340
+ document: clone(document),
341
+ infoPages: clone(infoPages),
342
+ profile: { ...profile },
343
+ };
344
+ await this.printNiimbotDocument(document, profile);
345
+ this.currentJob?.callback?.onProgress(document.pages.length, document.job.totalQuantity ?? 1, {
346
+ pages: document.pages.length,
347
+ profile: profile.label,
348
+ task: profile.task,
349
+ dpi: profile.dpi,
350
+ });
351
+ this.currentJob?.callback?.onBufferFree(document.pages.length, profile.printheadWidthPx);
352
+ }
353
+
354
+ async endJob(): Promise<boolean> {
355
+ return this.endPrintJob();
356
+ }
357
+
358
+ async endPrintJob(): Promise<boolean> {
359
+ this.printerState = this.server?.connected ? "Connected" : "Disconnected";
360
+ this.currentJob = undefined;
361
+ return true;
362
+ }
363
+
364
+ async cancelJob(): Promise<boolean> {
365
+ this.currentJob?.callback?.onCancelJob(true);
366
+ this.currentJob = undefined;
367
+ this.printerState = this.server?.connected ? "Connected" : "Disconnected";
368
+ return true;
369
+ }
370
+
371
+ async cancel(): Promise<void> {
372
+ await this.cancelJob();
373
+ }
374
+
375
+ async reopenPrinter(): Promise<boolean> {
376
+ if (this.reconnecting || !this.device?.gatt || this.server?.connected) {
377
+ return !!this.server?.connected;
378
+ }
379
+
380
+ this.reconnecting = true;
381
+ try {
382
+ const server = await this.device.gatt.connect();
383
+ this.server = server;
384
+ const service = await server.getPrimaryService(normalizeUuid(this.options.serviceUuid));
385
+ const characteristic = await service.getCharacteristic(normalizeUuid(this.options.characteristicUuid));
386
+ this.characteristic = characteristic;
387
+ characteristic.addEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
388
+ await characteristic.startNotifications();
389
+ await this.handshakeAndIdentify();
390
+ this.printerState = "Connected";
391
+ this.sdkCallback?.onConnectSuccess(this.lastAddress, 20);
392
+ return true;
393
+ } finally {
394
+ this.reconnecting = false;
395
+ }
396
+ }
397
+
398
+ async reopenPrinterSync(): Promise<boolean> {
399
+ return this.reopenPrinter();
400
+ }
401
+
402
+ async close(): Promise<void> {
403
+ this.currentJob?.callback?.onCancelJob(true);
404
+ this.currentJob = undefined;
405
+ this.printerState = "Disconnected";
406
+ if (this.device) {
407
+ this.device.removeEventListener("gattserverdisconnected", this.handleDisconnect);
408
+ }
409
+ if (this.characteristic) {
410
+ this.characteristic.removeEventListener("characteristicvaluechanged", this.handleCharacteristicChanged);
411
+ try {
412
+ await this.characteristic.stopNotifications();
413
+ } catch {
414
+ // Some browsers reject when the device is already gone.
415
+ }
416
+ }
417
+ if (this.server?.connected) {
418
+ this.server.disconnect();
419
+ }
420
+ this.server = undefined;
421
+ this.characteristic = undefined;
422
+ this.printerName = "";
423
+ this.lastAddress = "";
424
+ this.sdkCallback?.onDisConnect();
425
+ }
426
+
427
+ async quit(): Promise<void> {
428
+ await this.close();
429
+ }
430
+
431
+ async printBitmap(bitmap: BitmapLike, _bundle: BundleLike = {}): Promise<boolean> {
432
+ await this.writeJson({
433
+ kind: "jc-printer-sdk-ts/web-bluetooth-bitmap",
434
+ sentAt: new Date().toISOString(),
435
+ bitmap,
436
+ });
437
+ return true;
438
+ }
439
+
440
+ async printATBitmap(bitmap: IAtBitmapDocument, _bundle: BundleLike = {}): Promise<boolean> {
441
+ await this.writeJson({
442
+ kind: "jc-printer-sdk-ts/web-bluetooth-at-bitmap",
443
+ sentAt: new Date().toISOString(),
444
+ bitmap,
445
+ });
446
+ return true;
447
+ }
448
+
449
+ async getPaperInfo(): Promise<PaperInfo> {
450
+ return clone(this.paperInfo);
451
+ }
452
+
453
+ async isConnection(): Promise<number> {
454
+ return this.server?.connected ? 0 : -1;
455
+ }
456
+
457
+ async getPrintScaleMultiplier(): Promise<number> {
458
+ return 1;
459
+ }
460
+
461
+ async getPrinterName(): Promise<string> {
462
+ return this.printerName;
463
+ }
464
+
465
+ async getPrinterInfo(): Promise<PrinterInfo> {
466
+ return clone(this.printerInfo);
467
+ }
468
+
469
+ async getPrinterState(): Promise<PrinterState> {
470
+ return this.printerState;
471
+ }
472
+
473
+ async waitPrinterState(state: PrinterState, timeoutMs: number): Promise<boolean> {
474
+ const startedAt = Date.now();
475
+ while (Date.now() - startedAt < timeoutMs) {
476
+ if (this.printerState === state) {
477
+ return true;
478
+ }
479
+ await new Promise((resolve) => setTimeout(resolve, 100));
480
+ }
481
+ return false;
482
+ }
483
+
484
+ async measureFontHeight(
485
+ text: string,
486
+ _x: number,
487
+ _y: number,
488
+ _width: number,
489
+ _height: number,
490
+ _rotation: number,
491
+ fontSize: number,
492
+ ): Promise<number> {
493
+ const lines = text.split(/\r?\n/).length;
494
+ return Math.max(fontSize, Math.ceil(fontSize * 1.2 * lines));
495
+ }
496
+
497
+ async generatePreviewImage(json: string, info: string): Promise<BitmapLike> {
498
+ return {
499
+ width: 0,
500
+ height: 0,
501
+ description: "web-bluetooth-preview",
502
+ meta: { json, info },
503
+ };
504
+ }
505
+
506
+ async generatePrintPreviewImage(
507
+ json: string,
508
+ widthMm: number,
509
+ heightMm: number,
510
+ orientation: number,
511
+ ): Promise<BitmapLike> {
512
+ return {
513
+ width: widthMm,
514
+ height: heightMm,
515
+ description: "web-bluetooth-print-preview",
516
+ meta: { json, orientation },
517
+ };
518
+ }
519
+
520
+ setPrinterState(state: PrinterState): void {
521
+ this.printerState = state;
522
+ }
523
+
524
+ setPrinterInfo(info: PrinterInfo): void {
525
+ this.printerInfo = clone(info);
526
+ }
527
+
528
+ setPaperInfo(info: PaperInfo): void {
529
+ this.paperInfo = clone(info);
530
+ }
531
+
532
+ setScanResults(_results: PrinterDevice[]): void {
533
+ return;
534
+ }
535
+
536
+ emitPrintProgress(pageIndex: number, quantityIndex: number, meta?: Record<string, unknown>): void {
537
+ this.currentJob?.callback?.onProgress(pageIndex, quantityIndex, meta ?? {});
538
+ }
539
+
540
+ emitPrintError(errorCode: number, printState?: number): void {
541
+ this.currentJob?.callback?.onError(errorCode, printState);
542
+ }
543
+
544
+ emitBufferFree(pageIndex: number, bufferSize: number): void {
545
+ this.currentJob?.callback?.onBufferFree(pageIndex, bufferSize);
546
+ }
547
+
548
+ emitCancel(success: boolean): void {
549
+ this.currentJob?.callback?.onCancelJob(success);
550
+ }
551
+
552
+ private handleDisconnect = (): void => {
553
+ this.printerState = "Disconnected";
554
+ this.server = undefined;
555
+ this.characteristic = undefined;
556
+ this.sdkCallback?.onDisConnect();
557
+ };
558
+
559
+ private handleCharacteristicChanged = (event: Event): void => {
560
+ const characteristic = event.target as BluetoothRemoteGATTCharacteristic | null;
561
+ const value = characteristic?.value;
562
+ if (!value) {
563
+ return;
564
+ }
565
+
566
+ const frame = parseFrame(value);
567
+ if (!frame) {
568
+ return;
569
+ }
570
+
571
+ const response = { cmd: frame.cmd, data: frame.data };
572
+ const pending = this.pendingResponse;
573
+ if (pending && pending.expected.has(response.cmd)) {
574
+ clearTimeout(pending.timeoutId);
575
+ this.pendingResponse = undefined;
576
+ pending.resolve(response);
577
+ return;
578
+ }
579
+
580
+ this.responseQueue.push(response);
581
+ };
582
+
583
+ private async writePayload(payload: PayloadEnvelope): Promise<void> {
584
+ this.lastPayload = clone(payload);
585
+ await this.writeJson(payload);
586
+ this.currentJob?.callback?.onProgress(1, 1, {
587
+ bytes: JSON.stringify(payload).length,
588
+ });
589
+ this.currentJob?.callback?.onBufferFree(1, JSON.stringify(payload).length);
590
+ }
591
+
592
+ private async writeJson(payload: unknown): Promise<void> {
593
+ const characteristic = this.characteristic;
594
+ if (!characteristic) {
595
+ throw new Error("No writable Bluetooth characteristic is connected.");
596
+ }
597
+
598
+ const text = JSON.stringify(payload);
599
+ const encoded = new TextEncoder().encode(text);
600
+ const chunkSize = Math.max(20, this.options.chunkSize ?? 180);
601
+ const canWriteWithoutResponse = typeof characteristic.writeValueWithoutResponse === "function";
602
+
603
+ for (let offset = 0; offset < encoded.length; offset += chunkSize) {
604
+ const chunk = encoded.slice(offset, offset + chunkSize);
605
+ await this.writeBytes(chunk, 0, characteristic, canWriteWithoutResponse);
606
+ }
607
+ }
608
+
609
+ private async handshakeAndIdentify(): Promise<void> {
610
+ this.responseQueue = [];
611
+ this.connectedProfile = null;
612
+ this.b1HandshakeDone = false;
613
+
614
+ await this.writeBytes(createConnectFrame());
615
+ await this.waitForCommand([0xc2], 1500, true);
616
+
617
+ const status = await this.sendCommand(0xa5, [0x01], [0xb5], 1500, true);
618
+ const protocolVersion = status ? parseProtocolVersion(status.data) : undefined;
619
+ const model = await this.sendCommand(0x40, [0x08], [0x48], 1500, true);
620
+ const modelId = model ? parseModelId(model.data) : undefined;
621
+ this.connectedProfile = identifyProfile(modelId, protocolVersion);
622
+
623
+ if (this.connectedProfile?.task === "b1") {
624
+ await this.finishB1Handshake();
625
+ }
626
+
627
+ const profile = this.connectedProfile;
628
+ if (profile) {
629
+ this.printerInfo = {
630
+ ...this.printerInfo,
631
+ deviceType: profile.modelId ?? 0,
632
+ deviceDPI: profile.dpi,
633
+ deviceWidth: profile.printheadWidthPx,
634
+ seriesName: profile.label,
635
+ hardwareFlags: profile.modelId ?? 0,
636
+ softwareFlags: profile.protocolVersion ?? 0,
637
+ };
638
+ }
639
+ }
640
+
641
+ private async finishB1Handshake(): Promise<void> {
642
+ if (this.b1HandshakeDone) {
643
+ return;
644
+ }
645
+
646
+ for (const subCode of [0x0b, 0x0d, 0x0a, 0x07, 0x03, 0x0c, 0x09]) {
647
+ await this.sendCommand(0x40, [subCode], [0x40 + subCode], 1500, true);
648
+ }
649
+ await this.sendCommand(0xdc, [0x04], [0xd9], 1500, true);
650
+ this.b1HandshakeDone = true;
651
+ }
652
+
653
+ private async resolveProfile(document: PrintDocument): Promise<NiimbotPrinterProfile> {
654
+ const requestedTask = document.job.meta?.printTask;
655
+ const task = requestedTask === "b1" || requestedTask === "v4" ? requestedTask : "auto";
656
+ const requestedDpi = Number(document.job.meta?.dpi ?? 0);
657
+
658
+ if (task === "auto" && this.connectedProfile) {
659
+ return clone(this.connectedProfile);
660
+ }
661
+
662
+ const fallback = fallbackProfile(task, requestedDpi);
663
+ if (fallback.task === "b1") {
664
+ await this.finishB1Handshake();
665
+ }
666
+ return fallback;
667
+ }
668
+
669
+ private async printNiimbotDocument(document: PrintDocument, profile: NiimbotPrinterProfile): Promise<void> {
670
+ if (!document.pages.length) {
671
+ throw new Error("The print document has no pages.");
672
+ }
673
+
674
+ const copies = Math.max(1, Math.trunc(Number(document.job.totalQuantity ?? 1)));
675
+ const physicalPages = document.pages.length * copies;
676
+ const density = this.normalizeDensity(Number(document.job.density ?? 3), profile.task);
677
+ const speed = this.normalizeSpeed(Number(document.job.meta?.speed ?? 2));
678
+
679
+ this.currentJob?.callback?.onProgress(0, 0, {
680
+ phase: "protocol-start",
681
+ task: profile.task,
682
+ profile: profile.label,
683
+ dpi: profile.dpi,
684
+ physicalPages,
685
+ });
686
+
687
+ await this.sendCommand(0x21, [density], [0x31], 2000);
688
+ await this.sendCommand(0x23, [Number(document.job.paperType ?? 1) & 0xff], [0x33], 2000);
689
+
690
+ const startPayload =
691
+ profile.task === "v4"
692
+ ? [physicalPages >> 8, physicalPages & 0xff, 0, 0, 0, 0, 0, speed, 0]
693
+ : [physicalPages >> 8, physicalPages & 0xff, 0, 0, 0, 0, 0];
694
+ await this.sendCommand(0x01, startPayload, [0x02], 3000);
695
+ if (profile.task === "v4") {
696
+ await this.sendCommand(0xa3, [0x01], undefined, 0, true);
697
+ await delay(30);
698
+ }
699
+
700
+ let printedPage = 0;
701
+ for (let copyIndex = 0; copyIndex < copies; copyIndex += 1) {
702
+ for (let pageIndex = 0; pageIndex < document.pages.length; pageIndex += 1) {
703
+ const page = document.pages[pageIndex];
704
+ const raster = await rasterizeDocumentPage(page, document, profile);
705
+
706
+ if (profile.task === "b1") {
707
+ await this.sendCommand(0x03, [0x01], [0x04], 3000);
708
+ } else {
709
+ await this.sendCommand(0xa3, [0x01], undefined, 0, true);
710
+ await delay(30);
711
+ }
712
+
713
+ const sizePayload =
714
+ profile.task === "v4"
715
+ ? [
716
+ raster.heightPx >> 8,
717
+ raster.heightPx & 0xff,
718
+ raster.widthPx >> 8,
719
+ raster.widthPx & 0xff,
720
+ 0x00,
721
+ 0x01,
722
+ 0x00,
723
+ 0x00,
724
+ 0x00,
725
+ 0x00,
726
+ 0x00,
727
+ 0x00,
728
+ 0x00,
729
+ ]
730
+ : [
731
+ raster.heightPx >> 8,
732
+ raster.heightPx & 0xff,
733
+ raster.widthPx >> 8,
734
+ raster.widthPx & 0xff,
735
+ 0x00,
736
+ 0x01,
737
+ ];
738
+ await this.sendCommand(0x13, sizePayload, [0x14], 3000);
739
+
740
+ await this.writeRows(createRowFrames(raster), profile.paceMs);
741
+ await this.sendCommand(0xe3, [0x01], [0xe4], 8000);
742
+ printedPage += 1;
743
+ this.currentJob?.callback?.onProgress(pageIndex + 1, copyIndex + 1, {
744
+ phase: "page-sent",
745
+ page: printedPage,
746
+ total: physicalPages,
747
+ widthPx: raster.widthPx,
748
+ heightPx: raster.heightPx,
749
+ stride: raster.stride,
750
+ });
751
+ }
752
+ }
753
+
754
+ await this.waitUntilPrinted(physicalPages);
755
+ await this.sendCommand(0xf3, [0x01], [0xf4], 8000);
756
+ if (profile.task === "v4") {
757
+ await this.sendCommand(0xdc, [0x01], undefined, 0, true);
758
+ }
759
+ }
760
+
761
+ private async waitUntilPrinted(pageNumber: number): Promise<void> {
762
+ const startedAt = Date.now();
763
+ this.responseQueue = this.responseQueue.filter((response) => response.cmd !== 0xb3);
764
+
765
+ while (Date.now() - startedAt < 25000) {
766
+ const response = await this.sendCommand(0xa3, [0x01], [0xb3], 1200, true);
767
+ if (response?.data && response.data.length >= 2) {
768
+ const page = (response.data[0] << 8) | response.data[1];
769
+ if (page >= pageNumber) {
770
+ return;
771
+ }
772
+ }
773
+ await delay(400);
774
+ }
775
+ }
776
+
777
+ private async writeRows(frames: Uint8Array[], paceMs: number): Promise<void> {
778
+ for (const frame of frames) {
779
+ await this.writeBytes(frame, paceMs);
780
+ }
781
+ }
782
+
783
+ private async sendCommand(
784
+ cmd: number,
785
+ data: number[] | Uint8Array,
786
+ expected?: number[],
787
+ timeoutMs = 2000,
788
+ optional = false,
789
+ ): Promise<ParsedResponse | null> {
790
+ const wait = expected?.length ? this.waitForCommand(expected, timeoutMs, optional) : Promise.resolve(null);
791
+ await this.writeBytes(createFrame(cmd, data));
792
+ return wait;
793
+ }
794
+
795
+ private async waitForCommand(
796
+ expected: number[],
797
+ timeoutMs: number,
798
+ optional: boolean,
799
+ ): Promise<ParsedResponse | null> {
800
+ const expectedSet = new Set(expected.map((cmd) => cmd & 0xff));
801
+ const queuedIndex = this.responseQueue.findIndex((response) => expectedSet.has(response.cmd));
802
+ if (queuedIndex >= 0) {
803
+ const [queued] = this.responseQueue.splice(queuedIndex, 1);
804
+ return queued;
805
+ }
806
+
807
+ return new Promise((resolve, reject) => {
808
+ this.pendingResponse = {
809
+ expected: expectedSet,
810
+ resolve,
811
+ reject,
812
+ optional,
813
+ timeoutId: window.setTimeout(() => {
814
+ this.pendingResponse = undefined;
815
+ if (optional) {
816
+ resolve(null);
817
+ } else {
818
+ reject(new Error(`Timed out waiting for printer response 0x${expected[0].toString(16)}.`));
819
+ }
820
+ }, timeoutMs),
821
+ };
822
+ });
823
+ }
824
+
825
+ private async writeBytes(
826
+ bytes: Uint8Array,
827
+ paceMs = 0,
828
+ characteristic = this.characteristic,
829
+ canWriteWithoutResponse = !!characteristic && typeof characteristic.writeValueWithoutResponse === "function",
830
+ ): Promise<void> {
831
+ if (!characteristic) {
832
+ throw new Error("No writable Bluetooth characteristic is connected.");
833
+ }
834
+
835
+ const payload = new ArrayBuffer(bytes.byteLength);
836
+ new Uint8Array(payload).set(bytes);
837
+
838
+ if (canWriteWithoutResponse) {
839
+ await characteristic.writeValueWithoutResponse(payload);
840
+ } else {
841
+ await characteristic.writeValue(payload);
842
+ }
843
+
844
+ if (paceMs > 0) {
845
+ await delay(paceMs);
846
+ }
847
+ }
848
+
849
+ private normalizeDensity(value: number, task: "v4" | "b1"): number {
850
+ if (task === "b1") {
851
+ return Math.max(1, Math.min(5, Math.round(value)));
852
+ }
853
+ return Math.max(1, Math.min(14, Math.round(value)));
854
+ }
855
+
856
+ private normalizeSpeed(value: number): number {
857
+ return Math.max(0, Math.min(4, Math.round(value)));
858
+ }
859
+ }
860
+
861
+ function delay(ms: number): Promise<void> {
862
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
863
+ }
864
+
865
+ export function createWebBluetoothPrinterTransport(
866
+ options: Partial<WebBluetoothPrinterTransportOptions> = {},
867
+ ): WebBluetoothPrinterTransport {
868
+ return new WebBluetoothPrinterTransport(options);
869
+ }
870
+
871
+ export function createWebBluetoothPrinterClient(
872
+ options: Partial<WebBluetoothPrinterTransportOptions> = {},
873
+ sdkCallback?: PrinterSdkCallback,
874
+ ): JCPrintApi {
875
+ return new JCPrintApi(createWebBluetoothPrinterTransport(options), sdkCallback);
876
+ }