@nitra/zebra 6.1.7 → 6.2.0

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.
package/dist/plugin.js CHANGED
@@ -3,8 +3,8 @@ import { WebPlugin, registerPlugin } from '@capacitor/core';
3
3
  /**
4
4
  * ZebraPrinterWeb - Web implementation for development
5
5
  *
6
- * Uses local printer-server.js (TCP/IP) so developers can
7
- * test the printer in the browser.
6
+ * Uses local printer-server.js (TCP/IP) or Web Bluetooth API.
7
+ * Falls back to BLE when TCP/IP is not available (like iOS).
8
8
  *
9
9
  * Production (iOS) uses the native BLE implementation.
10
10
  */
@@ -24,12 +24,103 @@ import { WebPlugin, registerPlugin } from '@capacitor/core';
24
24
 
25
25
  const API_BASE = '/api/printer';
26
26
 
27
+ // Zebra printer BLE service UUIDs
28
+ const ZEBRA_SERVICE_UUID = '38eb4a80-c570-11e3-9507-0002a5d5c51b';
29
+ const ZEBRA_WRITE_CHAR_UUID = '38eb4a82-c570-11e3-9507-0002a5d5c51b';
30
+ // Alternative service for some Zebra models
31
+ const ZEBRA_SPP_SERVICE_UUID = '00001101-0000-1000-8000-00805f9b34fb';
32
+
33
+ // LocalStorage key for persisting printer settings
34
+ const STORAGE_KEY = 'zebra_printer_settings';
35
+
27
36
  class ZebraPrinterWeb extends WebPlugin {
28
37
  constructor() {
29
38
  super();
30
- this.connectedPrinter = null;
31
39
  this.serviceAvailable = null;
32
40
  this.defaultSubnet = this._getDefaultSubnet();
41
+ // BLE state
42
+ this.bleDevice = null;
43
+ this.bleServer = null;
44
+ this.bleCharacteristic = null;
45
+ this.discoveredBleDevices = [];
46
+ // Load last printer from localStorage
47
+ this.connectedPrinter = this._loadFromStorage();
48
+ if (this.connectedPrinter) {
49
+ console.log(
50
+ '[Storage] Loaded:',
51
+ this.connectedPrinter.name || this.connectedPrinter.ip
52
+ );
53
+ }
54
+ }
55
+
56
+ // ═══════════════════════════════════════════════════════════════════════════
57
+ // LOCAL STORAGE PERSISTENCE
58
+ // ═══════════════════════════════════════════════════════════════════════════
59
+
60
+ /**
61
+ * Load printer settings from localStorage
62
+ * @returns {Object|null} Saved printer settings or null
63
+ */
64
+ _loadFromStorage() {
65
+ try {
66
+ if (typeof localStorage === 'undefined') return null;
67
+ const saved = localStorage.getItem(STORAGE_KEY);
68
+ return saved ? JSON.parse(saved) : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Save printer settings to localStorage
76
+ * @param {Object} printer - Printer settings to save
77
+ */
78
+ _saveToStorage(printer) {
79
+ try {
80
+ if (typeof localStorage === 'undefined') return;
81
+ if (printer) {
82
+ // Don't save BLE device reference (can't be serialized)
83
+ const toSave = { ...printer };
84
+ delete toSave.bleDevice;
85
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
86
+ console.log('[Storage] Saved:', toSave.name || toSave.ip);
87
+ } else {
88
+ localStorage.removeItem(STORAGE_KEY);
89
+ console.log('[Storage] Cleared');
90
+ }
91
+ } catch {
92
+ // Ignore storage errors
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Clear saved printer from localStorage
98
+ * @returns {{success: boolean, message: string}} Result object
99
+ */
100
+ clearSavedPrinter() {
101
+ this._saveToStorage(null);
102
+ return { success: true, message: 'Saved printer cleared' };
103
+ }
104
+
105
+ /**
106
+ * Check if Web Bluetooth is supported
107
+ * @returns {boolean} True if Web Bluetooth API is available
108
+ */
109
+ _isBleSupported() {
110
+ return (
111
+ typeof navigator !== 'undefined' && navigator.bluetooth !== undefined
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Delay helper for BLE chunk sending
117
+ * @param {number} ms - Milliseconds to delay
118
+ * @returns {Promise<void>} Resolves after delay
119
+ */
120
+ _delay(ms) {
121
+ const { promise, resolve } = Promise.withResolvers();
122
+ setTimeout(resolve, ms);
123
+ return promise;
33
124
  }
34
125
 
35
126
  /**
@@ -56,6 +147,260 @@ class ZebraPrinterWeb extends WebPlugin {
56
147
  }
57
148
  }
58
149
 
150
+ /**
151
+ * Scan for BLE printers using Web Bluetooth API
152
+ * @returns {Promise<ScanResult>} List of discovered BLE printers
153
+ */
154
+ async _scanBlePrinters() {
155
+ if (!this._isBleSupported()) {
156
+ return {
157
+ success: false,
158
+ error: 'Web Bluetooth is not supported in this browser',
159
+ printers: [],
160
+ count: 0,
161
+ };
162
+ }
163
+
164
+ try {
165
+ console.log('[BLE] Scanning...');
166
+
167
+ // Request device with Zebra name prefixes
168
+ const device = await navigator.bluetooth.requestDevice({
169
+ filters: [
170
+ { namePrefix: 'Zebra' },
171
+ { namePrefix: 'ZT' },
172
+ { namePrefix: 'ZD' },
173
+ { namePrefix: 'ZQ' },
174
+ { namePrefix: 'XXZHN' },
175
+ ],
176
+ optionalServices: [ZEBRA_SERVICE_UUID, ZEBRA_SPP_SERVICE_UUID],
177
+ });
178
+
179
+ console.log('[BLE] Found:', device.name);
180
+
181
+ // Store discovered device
182
+ if (!this.discoveredBleDevices.some((d) => d.id === device.id)) {
183
+ this.discoveredBleDevices.push(device);
184
+ }
185
+
186
+ return {
187
+ success: true,
188
+ printers: this.discoveredBleDevices.map((d) => ({
189
+ name: d.name || 'Zebra Printer',
190
+ address: d.id,
191
+ type: 'bluetooth',
192
+ paired: false,
193
+ })),
194
+ count: this.discoveredBleDevices.length,
195
+ };
196
+ } catch (error) {
197
+ console.warn('[BLE] Scan failed:', error.message);
198
+ return {
199
+ success: false,
200
+ error: error.message || 'BLE scan failed',
201
+ printers: [],
202
+ count: 0,
203
+ };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Connect to a BLE printer
209
+ * @param {string} deviceId - Device ID from scan
210
+ * @returns {Promise<ConnectResult>} Connection result with success status
211
+ */
212
+ async _connectBle(deviceId) {
213
+ if (!this._isBleSupported()) {
214
+ return {
215
+ success: false,
216
+ connected: false,
217
+ error: 'Web Bluetooth is not supported',
218
+ };
219
+ }
220
+
221
+ try {
222
+ let device = this.discoveredBleDevices.find((d) => d.id === deviceId);
223
+
224
+ // If device not found, request new device
225
+ if (!device) {
226
+ console.log('[BLE] Device not cached, requesting...');
227
+ device = await navigator.bluetooth.requestDevice({
228
+ filters: [
229
+ { namePrefix: 'Zebra' },
230
+ { namePrefix: 'ZT' },
231
+ { namePrefix: 'ZD' },
232
+ { namePrefix: 'ZQ' },
233
+ { namePrefix: 'XXZHN' },
234
+ ],
235
+ optionalServices: [ZEBRA_SERVICE_UUID, ZEBRA_SPP_SERVICE_UUID],
236
+ });
237
+ this.discoveredBleDevices.push(device);
238
+ }
239
+
240
+ console.log('[BLE] Connecting to', device.name);
241
+
242
+ // Connect to GATT server
243
+ this.bleServer = await device.gatt.connect();
244
+ this.bleDevice = device;
245
+
246
+ console.log('[BLE] GATT connected');
247
+
248
+ // Try to get Zebra service
249
+ let service;
250
+ try {
251
+ service = await this.bleServer.getPrimaryService(ZEBRA_SERVICE_UUID);
252
+ console.log('[BLE] Found Zebra service');
253
+ } catch {
254
+ // Try alternative service
255
+ console.log('[BLE] Trying alternative services...');
256
+ const services = await this.bleServer.getPrimaryServices();
257
+ console.log('[BLE] Found', services.length, 'services');
258
+
259
+ for (const svc of services) {
260
+ console.log(` - Service: ${svc.uuid}`);
261
+ try {
262
+ const chars = await svc.getCharacteristics();
263
+ for (const char of chars) {
264
+ console.log(
265
+ ` - Characteristic: ${char.uuid}, props:`,
266
+ char.properties
267
+ );
268
+ if (
269
+ char.properties.write ||
270
+ char.properties.writeWithoutResponse
271
+ ) {
272
+ service = svc;
273
+ this.bleCharacteristic = char;
274
+ console.log('[BLE] Found writable characteristic');
275
+ break;
276
+ }
277
+ }
278
+ if (this.bleCharacteristic) break;
279
+ } catch {
280
+ console.warn('[BLE] Could not get characteristics for', svc.uuid);
281
+ }
282
+ }
283
+ }
284
+
285
+ // Get write characteristic if not found yet
286
+ if (service && !this.bleCharacteristic) {
287
+ try {
288
+ this.bleCharacteristic = await service.getCharacteristic(
289
+ ZEBRA_WRITE_CHAR_UUID
290
+ );
291
+ console.log('[BLE] Found Zebra write characteristic');
292
+ } catch {
293
+ // Find any writable characteristic
294
+ const chars = await service.getCharacteristics();
295
+ for (const char of chars) {
296
+ if (char.properties.write || char.properties.writeWithoutResponse) {
297
+ this.bleCharacteristic = char;
298
+ console.log('[BLE] Found alternative write characteristic');
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ if (!this.bleCharacteristic) {
306
+ throw new Error('No writable characteristic found on printer');
307
+ }
308
+
309
+ // Update connected printer state
310
+ this.connectedPrinter = {
311
+ name: device.name || 'Zebra Printer',
312
+ address: device.id,
313
+ type: 'bluetooth',
314
+ bleDevice: device,
315
+ };
316
+
317
+ // Save to localStorage (without bleDevice reference)
318
+ this._saveToStorage(this.connectedPrinter);
319
+
320
+ // Listen for disconnection
321
+ device.addEventListener('gattserverdisconnected', () => {
322
+ console.log('[BLE] Disconnected');
323
+ this.bleDevice = null;
324
+ this.bleServer = null;
325
+ this.bleCharacteristic = null;
326
+ if (this.connectedPrinter?.type === 'bluetooth') {
327
+ this.connectedPrinter = null;
328
+ }
329
+ });
330
+
331
+ return {
332
+ success: true,
333
+ connected: true,
334
+ address: device.id,
335
+ type: 'bluetooth',
336
+ message: `Connected to ${device.name}`,
337
+ };
338
+ } catch (error) {
339
+ console.error('[BLE] Connection failed:', error);
340
+ return {
341
+ success: false,
342
+ connected: false,
343
+ error: error.message || 'BLE connection failed',
344
+ };
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Send data to BLE printer in chunks
350
+ * @param {string} data - Data to send
351
+ * @returns {Promise<boolean>} True if data sent successfully
352
+ */
353
+ async _sendBlePrint(data) {
354
+ if (!this.bleCharacteristic) {
355
+ console.error('[BLE] No characteristic available');
356
+ return false;
357
+ }
358
+
359
+ try {
360
+ const encoder = new TextEncoder();
361
+ const bytes = encoder.encode(data);
362
+ const MTU_SIZE = 20; // Standard BLE MTU
363
+
364
+ console.log('[BLE] Sending', bytes.length, 'bytes');
365
+
366
+ for (let offset = 0; offset < bytes.length; offset += MTU_SIZE) {
367
+ const chunk = bytes.slice(
368
+ offset,
369
+ Math.min(offset + MTU_SIZE, bytes.length)
370
+ );
371
+
372
+ if (this.bleCharacteristic.properties.writeWithoutResponse) {
373
+ await this.bleCharacteristic.writeValueWithoutResponse(chunk);
374
+ } else {
375
+ await this.bleCharacteristic.writeValue(chunk);
376
+ }
377
+
378
+ // Small delay between chunks like iOS
379
+ if (offset + MTU_SIZE < bytes.length) {
380
+ await this._delay(50);
381
+ }
382
+ }
383
+
384
+ console.log('[BLE] Data sent');
385
+ return true;
386
+ } catch (error) {
387
+ console.error('[BLE] Send failed:', error);
388
+ return false;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Disconnect BLE printer
394
+ */
395
+ _disconnectBle() {
396
+ if (this.bleDevice?.gatt?.connected) {
397
+ this.bleDevice.gatt.disconnect();
398
+ }
399
+ this.bleDevice = null;
400
+ this.bleServer = null;
401
+ this.bleCharacteristic = null;
402
+ }
403
+
59
404
  /**
60
405
  * Fetch wrapper with error handling
61
406
  * @param {string} endpoint - API route
@@ -142,16 +487,20 @@ class ZebraPrinterWeb extends WebPlugin {
142
487
  */
143
488
  async checkPermissions() {
144
489
  const serviceOk = await this._checkService();
145
- const hasNavigator = typeof navigator !== 'undefined';
146
- const bleSupported = hasNavigator && navigator.bluetooth !== undefined;
490
+ const bleSupported = this._isBleSupported();
147
491
 
148
- if (!serviceOk) {
149
- console.warn('ZebraPrinter: printer-server not available');
492
+ // With BLE support, we can work even without printer-server
493
+ const hasPermissions = serviceOk || bleSupported;
494
+
495
+ if (!hasPermissions) {
496
+ console.warn(
497
+ 'ZebraPrinter: no print method available (no printer-server, no BLE)'
498
+ );
150
499
  return {
151
500
  hasPermissions: false,
152
- missingPermissions: ['printer-server'],
153
- bluetoothSupported: bleSupported,
154
- bleSupported: bleSupported,
501
+ missingPermissions: ['printer-server', 'bluetooth'],
502
+ bluetoothSupported: false,
503
+ bleSupported: false,
155
504
  webMode: true,
156
505
  serviceAvailable: false,
157
506
  };
@@ -163,7 +512,10 @@ class ZebraPrinterWeb extends WebPlugin {
163
512
  bluetoothSupported: bleSupported,
164
513
  bleSupported: bleSupported,
165
514
  webMode: true,
166
- serviceAvailable: true,
515
+ serviceAvailable: serviceOk,
516
+ // New: indicate which methods are available
517
+ tcpAvailable: serviceOk,
518
+ bleAvailable: bleSupported,
167
519
  };
168
520
  }
169
521
 
@@ -194,6 +546,29 @@ class ZebraPrinterWeb extends WebPlugin {
194
546
 
195
547
  const zplPayload = this._wrapTextIfNeeded(text);
196
548
 
549
+ // If connected via BLE, use BLE printing
550
+ if (this.connectedPrinter?.type === 'bluetooth' && this.bleCharacteristic) {
551
+ console.log('[BLE] Printing...');
552
+ const success = await this._sendBlePrint(zplPayload);
553
+
554
+ if (success) {
555
+ return {
556
+ success: true,
557
+ message: 'Print successful (BLE)',
558
+ zpl: `${zplPayload.slice(0, 100)}...`,
559
+ bytes: zplPayload.length,
560
+ method: 'bluetooth',
561
+ };
562
+ }
563
+
564
+ return {
565
+ success: false,
566
+ message: 'BLE print failed',
567
+ error: 'Failed to send data to printer via Bluetooth',
568
+ };
569
+ }
570
+
571
+ // Otherwise use TCP/IP via printer-server
197
572
  const result = await this._fetch('/print', {
198
573
  method: 'POST',
199
574
  body: JSON.stringify({
@@ -208,6 +583,7 @@ class ZebraPrinterWeb extends WebPlugin {
208
583
  message: 'Print successful',
209
584
  zpl: `${zplPayload.slice(0, 100)}...`,
210
585
  bytes: zplPayload.length,
586
+ method: 'tcp',
211
587
  };
212
588
  }
213
589
 
@@ -230,6 +606,20 @@ class ZebraPrinterWeb extends WebPlugin {
230
606
  };
231
607
  }
232
608
 
609
+ // BLE connection status
610
+ if (this.connectedPrinter.type === 'bluetooth') {
611
+ const isConnected =
612
+ this.bleDevice?.gatt?.connected === true &&
613
+ this.bleCharacteristic !== null;
614
+ return {
615
+ connected: isConnected,
616
+ status: isConnected ? 'connected' : 'disconnected',
617
+ printerAddress: this.connectedPrinter.address,
618
+ printerType: 'bluetooth',
619
+ };
620
+ }
621
+
622
+ // TCP/IP status
233
623
  const result = await this._fetch('/check', {
234
624
  method: 'POST',
235
625
  body: JSON.stringify({ ip: this.connectedPrinter.ip }),
@@ -256,11 +646,26 @@ class ZebraPrinterWeb extends WebPlugin {
256
646
  };
257
647
  }
258
648
 
259
- // On web we send ~HS command
649
+ const statusCommand = '~HS';
650
+
651
+ // BLE status check
652
+ if (this.connectedPrinter.type === 'bluetooth' && this.bleCharacteristic) {
653
+ const success = await this._sendBlePrint(statusCommand);
654
+ return {
655
+ success,
656
+ message: success
657
+ ? 'Status command sent (BLE)'
658
+ : 'Failed to send status command',
659
+ command: statusCommand,
660
+ method: 'bluetooth',
661
+ };
662
+ }
663
+
664
+ // TCP/IP status check
260
665
  const result = await this._fetch('/print', {
261
666
  method: 'POST',
262
667
  body: JSON.stringify({
263
- zpl: '~HS',
668
+ zpl: statusCommand,
264
669
  ip: this.connectedPrinter.ip,
265
670
  }),
266
671
  });
@@ -268,36 +673,78 @@ class ZebraPrinterWeb extends WebPlugin {
268
673
  return {
269
674
  success: result.success,
270
675
  message: result.success ? 'Status command sent' : result.error,
271
- command: '~HS',
676
+ command: statusCommand,
677
+ method: 'tcp',
272
678
  };
273
679
  }
274
680
 
275
681
  /**
276
682
  * Scan for available printers
277
- * @param {{subnet?: string}} [options] - Custom subnet (e.g., "192.168.0")
683
+ * @param {{subnet?: string, useBle?: boolean, skipCache?: boolean}} [options] - Custom subnet or BLE mode
278
684
  * @returns {Promise<ScanResult>} List of found printers
279
685
  */
280
686
  async scanForPrinters(options = {}) {
687
+ const { useBle, skipCache } = options;
281
688
  const serviceOk = await this._checkService();
689
+ const bleSupported = this._isBleSupported();
282
690
 
283
- if (!serviceOk) {
284
- return {
285
- success: false,
286
- error:
287
- 'Printer service is not available. Add printer-server.js and vite-plugin-zebra-printer.js to your project.',
288
- printers: [],
289
- count: 0,
290
- };
691
+ // Check if last saved printer is still reachable (fast reconnect)
692
+ if (!skipCache && !useBle) {
693
+ const lastPrinter = this._loadFromStorage();
694
+ if (lastPrinter?.ip && serviceOk) {
695
+ console.log('[Cache] Checking:', lastPrinter.ip);
696
+ const check = await this._fetch('/check', {
697
+ method: 'POST',
698
+ body: JSON.stringify({ ip: lastPrinter.ip }),
699
+ });
700
+
701
+ if (check.reachable) {
702
+ console.log('[Cache] Printer reachable');
703
+ // Auto-connect to last printer
704
+ this.connectedPrinter = lastPrinter;
705
+ return {
706
+ success: true,
707
+ printers: [
708
+ {
709
+ name: lastPrinter.name || `Zebra @ ${lastPrinter.ip}`,
710
+ address: lastPrinter.ip,
711
+ type: 'network',
712
+ paired: true, // Mark as "paired" since it was saved
713
+ },
714
+ ],
715
+ count: 1,
716
+ fromCache: true,
717
+ autoConnected: true,
718
+ };
719
+ }
720
+ console.log('[Cache] Printer not reachable, scanning...');
721
+ }
722
+ }
723
+
724
+ // If explicitly requesting BLE or printer-server not available
725
+ if (useBle || !serviceOk) {
726
+ if (!bleSupported) {
727
+ return {
728
+ success: false,
729
+ error:
730
+ 'Web Bluetooth is not supported in this browser. Use Chrome or Edge.',
731
+ printers: [],
732
+ count: 0,
733
+ };
734
+ }
735
+
736
+ console.log('[Web] Using BLE scan...');
737
+ return await this._scanBlePrinters();
291
738
  }
292
739
 
293
740
  const subnet = options.subnet || this.defaultSubnet || '192.168.1';
294
741
 
295
- // Scan the network (can be slow)
742
+ // Try TCP/IP scan first
296
743
  const result = await this._fetch(
297
744
  `/scan?subnet=${encodeURIComponent(subnet)}`
298
745
  );
299
746
 
300
- if (result.success) {
747
+ if (result.success && result.printers?.length > 0) {
301
748
  return {
302
749
  success: true,
303
750
  printers: (result.printers || []).map((p) => ({
@@ -307,14 +754,30 @@ class ZebraPrinterWeb extends WebPlugin {
307
754
  paired: false,
308
755
  })),
309
756
  count: result.printers?.length || 0,
757
+ method: 'tcp',
310
758
  };
311
759
  }
312
760
 
761
+ // Fallback to BLE if TCP/IP found nothing and BLE is supported
762
+ if (bleSupported) {
763
+ console.log('[Web] TCP/IP found nothing, trying BLE...');
764
+ const bleResult = await this._scanBlePrinters();
765
+
766
+ if (bleResult.success) {
767
+ return {
768
+ ...bleResult,
769
+ method: 'bluetooth',
770
+ tcpFallback: true,
771
+ };
772
+ }
773
+ }
774
+
313
775
  return {
314
776
  success: false,
315
- error: result.error || 'Scan failed',
777
+ error: result.error || 'No printers found',
316
778
  printers: [],
317
779
  count: 0,
780
+ bleAvailable: bleSupported,
318
781
  };
319
782
  }
320
783
 
@@ -326,24 +789,45 @@ class ZebraPrinterWeb extends WebPlugin {
326
789
  async connect(options) {
327
790
  const { address, type } = options || {};
328
791
 
329
- if (!address) {
330
- return {
331
- success: false,
332
- connected: false,
333
- error: 'Printer address (IP) not provided',
334
- };
792
+ // BLE connection (like iOS)
793
+ if (type === 'bluetooth') {
794
+ if (!this._isBleSupported()) {
795
+ return {
796
+ success: false,
797
+ connected: false,
798
+ error:
799
+ 'Web Bluetooth is not supported in this browser. Use Chrome or Edge.',
800
+ };
801
+ }
802
+
803
+ console.log('[Web] Connecting via BLE...');
804
+ return await this._connectBle(address);
335
805
  }
336
806
 
337
- // On web we support only network connections
338
- if (type === 'bluetooth') {
807
+ // If no address provided but BLE devices were discovered, connect via BLE
808
+ if (!address && this.discoveredBleDevices.length > 0) {
809
+ console.log('[Web] No IP, using discovered BLE device...');
810
+ return await this._connectBle(this.discoveredBleDevices[0].id);
811
+ }
812
+
813
+ if (!address) {
814
+ // No address and no BLE devices - try to scan BLE
815
+ if (this._isBleSupported()) {
816
+ console.log('[Web] No address, scanning BLE...');
817
+ const scanResult = await this._scanBlePrinters();
818
+ if (scanResult.success && scanResult.printers.length > 0) {
819
+ return await this._connectBle(scanResult.printers[0].address);
820
+ }
821
+ }
822
+
339
823
  return {
340
824
  success: false,
341
825
  connected: false,
342
- error:
343
- 'Bluetooth is not available on web. Use IP address or test in the iOS app.',
826
+ error: 'Printer address (IP) not provided',
344
827
  };
345
828
  }
346
829
 
830
+ // TCP/IP connection via printer-server
347
831
  const result = await this._fetch('/connect', {
348
832
  method: 'POST',
349
833
  body: JSON.stringify({ ip: address }),
@@ -356,6 +840,9 @@ class ZebraPrinterWeb extends WebPlugin {
356
840
  type: 'network',
357
841
  };
358
842
 
843
+ // Save to localStorage for quick reconnect
844
+ this._saveToStorage(this.connectedPrinter);
845
+
359
846
  return {
360
847
  success: true,
361
848
  connected: true,
@@ -365,6 +852,12 @@ class ZebraPrinterWeb extends WebPlugin {
365
852
  };
366
853
  }
367
854
 
855
+ // If TCP connection failed and BLE is supported, offer BLE as fallback
856
+ if (this._isBleSupported()) {
857
+ console.log('[Web] TCP failed, trying BLE...');
858
+ return await this._connectBle(address);
859
+ }
860
+
368
861
  return {
369
862
  success: false,
370
863
  connected: false,
@@ -411,9 +904,20 @@ class ZebraPrinterWeb extends WebPlugin {
411
904
 
412
905
  /**
413
906
  * Disconnect from the printer
907
+ * @param {{clearSaved?: boolean}} [options] - Options for disconnect
414
908
  * @returns {Promise<DisconnectResult>} Disconnect result
415
909
  */
416
- async disconnect() {
910
+ async disconnect(options = {}) {
911
+ // Disconnect BLE if connected
912
+ if (this.connectedPrinter?.type === 'bluetooth') {
913
+ this._disconnectBle();
914
+ }
915
+
916
+ // Optionally clear saved printer
917
+ if (options.clearSaved) {
918
+ this._saveToStorage(null);
919
+ }
920
+
417
921
  this.connectedPrinter = null;
418
922
  await Promise.resolve();
419
923
  return {
@@ -421,6 +925,367 @@ class ZebraPrinterWeb extends WebPlugin {
421
925
  connected: false,
422
926
  };
423
927
  }
928
+
929
+ /**
930
+ * Get device network info (web not supported directly)
931
+ * @returns {Promise<{supported: boolean, type: string, error?: string, ip?: string, ssid?: string}>} Network info proxied via printer-server if available
932
+ */
933
+ async getNetworkInfo() {
934
+ const serviceOk = await this._checkService();
935
+ if (!serviceOk) {
936
+ return {
937
+ supported: false,
938
+ type: 'unknown',
939
+ error: 'Printer service not available for network info',
940
+ };
941
+ }
942
+
943
+ const result = await this._fetch('/network-info', { method: 'GET' });
944
+
945
+ if (result?.success) {
946
+ return {
947
+ supported: true,
948
+ type: result.type || 'unknown',
949
+ ip: result.ip,
950
+ ssid: result.ssid,
951
+ };
952
+ }
953
+
954
+ return {
955
+ supported: false,
956
+ type: 'unknown',
957
+ error: result?.error || 'Network info not available',
958
+ };
959
+ }
960
+
961
+ // ═══════════════════════════════════════════════════════════════════════════
962
+ // DEV HELPER METHODS
963
+ // ═══════════════════════════════════════════════════════════════════════════
964
+
965
+ /**
966
+ * Get saved printer from localStorage
967
+ * @returns {Object|null} Saved printer settings
968
+ */
969
+ getSavedPrinter() {
970
+ return this._loadFromStorage();
971
+ }
972
+
973
+ /**
974
+ * Try to auto-connect to saved printer
975
+ * @returns {Promise<ConnectResult>} Connection result
976
+ */
977
+ async autoConnect() {
978
+ const savedPrinter = this._loadFromStorage();
979
+
980
+ if (!savedPrinter) {
981
+ return {
982
+ success: false,
983
+ connected: false,
984
+ error: 'No saved printer found',
985
+ };
986
+ }
987
+
988
+ console.log('[AutoConnect] Trying:', savedPrinter.name);
989
+
990
+ // For BLE printers, need to re-scan and connect
991
+ if (savedPrinter.type === 'bluetooth') {
992
+ return await this.connect({
993
+ type: 'bluetooth',
994
+ address: savedPrinter.address,
995
+ });
996
+ }
997
+
998
+ // For TCP/IP printers, check if reachable first
999
+ if (savedPrinter.ip) {
1000
+ const check = await this._fetch('/check', {
1001
+ method: 'POST',
1002
+ body: JSON.stringify({ ip: savedPrinter.ip }),
1003
+ });
1004
+
1005
+ if (check.reachable) {
1006
+ this.connectedPrinter = savedPrinter;
1007
+ return {
1008
+ success: true,
1009
+ connected: true,
1010
+ address: savedPrinter.ip,
1011
+ type: 'network',
1012
+ message: 'Auto-connected to saved printer',
1013
+ fromCache: true,
1014
+ };
1015
+ }
1016
+ }
1017
+
1018
+ return {
1019
+ success: false,
1020
+ connected: false,
1021
+ error: 'Saved printer not reachable',
1022
+ };
1023
+ }
1024
+
1025
+ /**
1026
+ * Show PWA-style printer picker dialog
1027
+ * Creates a native-feeling modal for printer selection
1028
+ * @param {{title?: string, scanOnOpen?: boolean}} [options] - Dialog options
1029
+ * @returns {Promise<{success: boolean, printer?: Object, cancelled?: boolean}>} Selected printer or cancellation
1030
+ */
1031
+ async showPrinterPicker(options = {}) {
1032
+ const { title = 'Select Printer', scanOnOpen = true } = options;
1033
+
1034
+ const { promise, resolve } = Promise.withResolvers();
1035
+
1036
+ // Create modal container
1037
+ const overlay = document.createElement('div');
1038
+ overlay.id = 'zebra-printer-picker';
1039
+ overlay.innerHTML = this._getPickerHTML(title);
1040
+ document.body.append(overlay);
1041
+
1042
+ // Store state for this picker instance
1043
+ const pickerState = {
1044
+ printers: [],
1045
+ resolve,
1046
+ cleanup: () => overlay.remove(),
1047
+ };
1048
+
1049
+ // Setup event handlers
1050
+ this._setupPickerEvents(overlay, pickerState);
1051
+
1052
+ // Auto-scan if requested
1053
+ if (scanOnOpen) {
1054
+ await this._updatePickerPrinters(overlay, {});
1055
+ }
1056
+
1057
+ return promise;
1058
+ }
1059
+
1060
+ /**
1061
+ * Get picker HTML template
1062
+ * @private
1063
+ * @param {string} title - Dialog title
1064
+ * @returns {string} HTML string
1065
+ */
1066
+ _getPickerHTML(title) {
1067
+ const bleButton = this._isBleSupported()
1068
+ ? '<button class="zpp-btn zpp-btn-ble" data-action="ble">BLE</button>'
1069
+ : '';
1070
+
1071
+ return `
1072
+ <style>
1073
+ #zebra-printer-picker {
1074
+ position: fixed;
1075
+ inset: 0;
1076
+ z-index: 999999;
1077
+ display: flex;
1078
+ align-items: center;
1079
+ justify-content: center;
1080
+ background: rgba(0, 0, 0, 0.5);
1081
+ backdrop-filter: blur(4px);
1082
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1083
+ }
1084
+ .zpp-modal {
1085
+ background: white;
1086
+ border-radius: 16px;
1087
+ width: 90%;
1088
+ max-width: 400px;
1089
+ max-height: 80vh;
1090
+ overflow: hidden;
1091
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
1092
+ animation: zpp-slideUp 0.3s ease-out;
1093
+ }
1094
+ @keyframes zpp-slideUp {
1095
+ from { opacity: 0; transform: translateY(20px); }
1096
+ to { opacity: 1; transform: translateY(0); }
1097
+ }
1098
+ .zpp-header {
1099
+ padding: 20px;
1100
+ border-bottom: 1px solid #e5e7eb;
1101
+ display: flex;
1102
+ align-items: center;
1103
+ justify-content: space-between;
1104
+ }
1105
+ .zpp-header h2 { margin: 0; font-size: 18px; font-weight: 600; color: #111827; }
1106
+ .zpp-close { background: none; border: none; padding: 8px; cursor: pointer; border-radius: 8px; color: #6b7280; font-size: 18px; }
1107
+ .zpp-close:hover { background: #f3f4f6; }
1108
+ .zpp-content { padding: 16px; max-height: 400px; overflow-y: auto; }
1109
+ .zpp-loading { text-align: center; padding: 40px 20px; color: #6b7280; }
1110
+ .zpp-spinner {
1111
+ width: 40px; height: 40px;
1112
+ border: 3px solid #e5e7eb; border-top-color: #3b82f6;
1113
+ border-radius: 50%; animation: zpp-spin 1s linear infinite;
1114
+ margin: 0 auto 16px;
1115
+ }
1116
+ @keyframes zpp-spin { to { transform: rotate(360deg); } }
1117
+ .zpp-list { list-style: none; margin: 0; padding: 0; }
1118
+ .zpp-item {
1119
+ display: flex; align-items: center; padding: 16px;
1120
+ border: 1px solid #e5e7eb; border-radius: 12px;
1121
+ margin-bottom: 8px; cursor: pointer; transition: all 0.15s ease;
1122
+ }
1123
+ .zpp-item:hover { border-color: #3b82f6; background: #eff6ff; }
1124
+ .zpp-item.saved { border-color: #10b981; background: #ecfdf5; }
1125
+ .zpp-icon {
1126
+ width: 40px; height: 40px; background: #f3f4f6; border-radius: 10px;
1127
+ display: flex; align-items: center; justify-content: center;
1128
+ margin-right: 12px; font-size: 20px;
1129
+ }
1130
+ .zpp-info { flex: 1; }
1131
+ .zpp-name { font-weight: 500; color: #111827; margin-bottom: 2px; }
1132
+ .zpp-addr { font-size: 13px; color: #6b7280; }
1133
+ .zpp-badge { font-size: 11px; padding: 2px 8px; border-radius: 9999px; background: #10b981; color: white; margin-left: 8px; }
1134
+ .zpp-empty { text-align: center; padding: 40px 20px; color: #6b7280; }
1135
+ .zpp-actions { padding: 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; }
1136
+ .zpp-btn {
1137
+ flex: 1; padding: 12px 16px; border-radius: 10px;
1138
+ font-size: 14px; font-weight: 500; cursor: pointer;
1139
+ border: none; transition: all 0.15s ease;
1140
+ }
1141
+ .zpp-btn-secondary { background: #f3f4f6; color: #374151; }
1142
+ .zpp-btn-secondary:hover { background: #e5e7eb; }
1143
+ .zpp-btn-primary { background: #3b82f6; color: white; }
1144
+ .zpp-btn-primary:hover { background: #2563eb; }
1145
+ .zpp-btn-ble { background: #8b5cf6; color: white; }
1146
+ .zpp-btn-ble:hover { background: #7c3aed; }
1147
+ </style>
1148
+ <div class="zpp-modal">
1149
+ <div class="zpp-header">
1150
+ <h2>${title}</h2>
1151
+ <button class="zpp-close" data-action="close">✕</button>
1152
+ </div>
1153
+ <div class="zpp-content">
1154
+ <div class="zpp-loading">
1155
+ <div class="zpp-spinner"></div>
1156
+ <div>Searching for printers...</div>
1157
+ </div>
1158
+ </div>
1159
+ <div class="zpp-actions">
1160
+ <button class="zpp-btn zpp-btn-secondary" data-action="cancel">Cancel</button>
1161
+ <button class="zpp-btn zpp-btn-primary" data-action="rescan">Rescan</button>
1162
+ ${bleButton}
1163
+ </div>
1164
+ </div>
1165
+ `;
1166
+ }
1167
+
1168
+ /**
1169
+ * Setup picker event handlers
1170
+ * @private
1171
+ * @param {HTMLElement} overlay - Picker overlay element
1172
+ * @param {Object} state - Picker state object
1173
+ */
1174
+ _setupPickerEvents(overlay, state) {
1175
+ // Handle button clicks
1176
+ overlay.addEventListener('click', async (e) => {
1177
+ const action = e.target.dataset?.action;
1178
+
1179
+ if (action === 'close' || action === 'cancel') {
1180
+ state.cleanup();
1181
+ state.resolve({ success: false, cancelled: true });
1182
+ return;
1183
+ }
1184
+
1185
+ if (action === 'rescan') {
1186
+ await this._updatePickerPrinters(overlay, { skipCache: true });
1187
+ return;
1188
+ }
1189
+
1190
+ if (action === 'ble') {
1191
+ await this._updatePickerPrinters(overlay, { useBle: true });
1192
+ return;
1193
+ }
1194
+
1195
+ // Check if clicked on printer item
1196
+ const item = e.target.closest('.zpp-item');
1197
+ if (item) {
1198
+ const printer = {
1199
+ address: item.dataset.address,
1200
+ type: item.dataset.type,
1201
+ name: item.dataset.name,
1202
+ };
1203
+
1204
+ state.cleanup();
1205
+
1206
+ // Auto-connect to selected printer
1207
+ const connectResult = await this.connect({
1208
+ address: printer.address,
1209
+ type: printer.type,
1210
+ });
1211
+
1212
+ state.resolve({
1213
+ success: connectResult.success,
1214
+ printer: connectResult.success ? printer : undefined,
1215
+ error: connectResult.error,
1216
+ });
1217
+ }
1218
+ });
1219
+
1220
+ // Close on overlay click (outside modal)
1221
+ overlay.addEventListener('click', (e) => {
1222
+ if (e.target === overlay) {
1223
+ state.cleanup();
1224
+ state.resolve({ success: false, cancelled: true });
1225
+ }
1226
+ });
1227
+ }
1228
+
1229
+ /**
1230
+ * Update picker with scanned printers
1231
+ * @private
1232
+ * @param {HTMLElement} overlay - Picker overlay element
1233
+ * @param {Object} options - Scan options
1234
+ */
1235
+ async _updatePickerPrinters(overlay, options) {
1236
+ const content = overlay.querySelector('.zpp-content');
1237
+ const loadingMsg = options.useBle
1238
+ ? 'Scanning for Bluetooth printers...'
1239
+ : 'Searching for printers...';
1240
+
1241
+ content.innerHTML = `
1242
+ <div class="zpp-loading">
1243
+ <div class="zpp-spinner"></div>
1244
+ <div>${loadingMsg}</div>
1245
+ </div>
1246
+ `;
1247
+
1248
+ const result = await this.scanForPrinters(options);
1249
+ const printers = result.printers || [];
1250
+
1251
+ if (printers.length === 0) {
1252
+ content.innerHTML = `
1253
+ <div class="zpp-empty">
1254
+ <div style="font-size: 32px; margin-bottom: 16px; opacity: 0.5;">⎙</div>
1255
+ <div>No printers found</div>
1256
+ <div style="font-size: 13px; margin-top: 8px;">Make sure the printer is on and connected to the network</div>
1257
+ </div>
1258
+ `;
1259
+ return;
1260
+ }
1261
+
1262
+ const savedPrinter = this._loadFromStorage();
1263
+ let html = '<ul class="zpp-list">';
1264
+
1265
+ for (const p of printers) {
1266
+ const isSaved =
1267
+ savedPrinter?.ip === p.address || savedPrinter?.address === p.address;
1268
+ const icon = p.type === 'bluetooth' ? 'BT' : 'IP';
1269
+ const typeLabel = p.type === 'bluetooth' ? 'Bluetooth' : 'Network';
1270
+ const badge = p.paired ? '<span class="zpp-badge">Saved</span>' : '';
1271
+
1272
+ html += `
1273
+ <li class="zpp-item ${isSaved ? 'saved' : ''}"
1274
+ data-address="${p.address}"
1275
+ data-type="${p.type}"
1276
+ data-name="${p.name}">
1277
+ <div class="zpp-icon">${icon}</div>
1278
+ <div class="zpp-info">
1279
+ <div class="zpp-name">${p.name}${badge}</div>
1280
+ <div class="zpp-addr">${p.address} • ${typeLabel}</div>
1281
+ </div>
1282
+ </li>
1283
+ `;
1284
+ }
1285
+
1286
+ html += '</ul>';
1287
+ content.innerHTML = html;
1288
+ }
424
1289
  }
425
1290
 
426
1291
  const ZebraPrinter = registerPlugin('ZebraPrinter', {