@opendisplay/opendisplay 1.0.0 → 1.1.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/README.md ADDED
@@ -0,0 +1,441 @@
1
+ # @opendisplay/opendisplay
2
+
3
+ TypeScript library for OpenDisplay BLE e-paper displays using Web Bluetooth API. Control your OpenDisplay devices directly from the browser.
4
+
5
+ [![npm version](https://badge.fury.io/js/@opendisplay%2Fopendisplay.svg)](https://www.npmjs.com/package/@opendisplay/opendisplay)
6
+
7
+ ## Features
8
+
9
+ - **Web Bluetooth Integration**: Control OpenDisplay devices directly from the browser
10
+ - **Complete Protocol Support**: Image upload, device configuration, firmware management
11
+ - **Automatic Image Processing**: Built-in dithering, encoding, and compression
12
+ - **Type-Safe**: Full TypeScript support with exported types
13
+ - **Device Discovery**: Browser-native device picker
14
+ ## Browser Compatibility
15
+
16
+ Web Bluetooth API is required. Supported browsers:
17
+
18
+ - ✅ Chrome/Edge 56+ (Desktop & Android)
19
+ - ✅ Opera 43+ (Desktop & Android)
20
+ - ✅ Samsung Internet 6.0+
21
+ - ❌ Firefox (no Web Bluetooth support)
22
+ - ❌ Safari (no Web Bluetooth support)
23
+
24
+ **Note**: HTTPS or localhost is required for Web Bluetooth API.
25
+
26
+ ## Installation
27
+
28
+ ### NPM/Bun/Yarn
29
+
30
+ ```bash
31
+ npm install @opendisplay/opendisplay
32
+ # or
33
+ bun add @opendisplay/opendisplay
34
+ # or
35
+ yarn add @opendisplay/opendisplay
36
+ ```
37
+
38
+ ### CDN (for quick prototyping)
39
+
40
+ ```html
41
+ <script type="module">
42
+ import { OpenDisplayDevice } from 'https://esm.sh/@opendisplay/opendisplay@1.0.0';
43
+ // Your code here
44
+ </script>
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ```typescript
50
+ import { OpenDisplayDevice, DitherMode, RefreshMode } from '@opendisplay/opendisplay';
51
+
52
+ // Create device instance
53
+ const device = new OpenDisplayDevice();
54
+
55
+ // Connect to device (shows browser picker)
56
+ await device.connect();
57
+
58
+ // Device is auto-interrogated on first connect
59
+ console.log(`Connected to ${device.width}x${device.height} display`);
60
+
61
+ // Load image from canvas
62
+ const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
63
+ const ctx = canvas.getContext('2d')!;
64
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
65
+
66
+ // Upload image
67
+ await device.uploadImage(imageData, {
68
+ refreshMode: RefreshMode.FULL,
69
+ ditherMode: DitherMode.FLOYD_STEINBERG,
70
+ compress: true
71
+ });
72
+
73
+ // Disconnect when done
74
+ await device.disconnect();
75
+ ```
76
+
77
+ ## API Documentation
78
+
79
+ ### OpenDisplayDevice
80
+
81
+ Main class for interacting with OpenDisplay devices.
82
+
83
+ #### Constructor
84
+
85
+ ```typescript
86
+ new OpenDisplayDevice(options?: {
87
+ config?: GlobalConfig;
88
+ capabilities?: DeviceCapabilities;
89
+ device?: BluetoothDevice;
90
+ namePrefix?: string;
91
+ })
92
+ ```
93
+
94
+ **Options:**
95
+ - `config`: Cached device configuration to skip interrogation
96
+ - `capabilities`: Minimal device info (width, height, color scheme) to skip interrogation
97
+ - `device`: Pre-selected BluetoothDevice instance
98
+ - `namePrefix`: Device name filter for picker (e.g., "OpenDisplay")
99
+
100
+ #### Methods
101
+
102
+ ##### `connect(options?: BLEConnectionOptions): Promise<void>`
103
+
104
+ Connect to an OpenDisplay device. Shows browser's Bluetooth device picker if no device was provided in constructor.
105
+
106
+ ```typescript
107
+ await device.connect();
108
+ // or with name filter
109
+ await device.connect({ namePrefix: 'OpenDisplay' });
110
+ ```
111
+
112
+ Automatically interrogates device on first connect unless config/capabilities were provided.
113
+
114
+ ##### `disconnect(): Promise<void>`
115
+
116
+ Disconnect from the device.
117
+
118
+ ```typescript
119
+ await device.disconnect();
120
+ ```
121
+
122
+ ##### `uploadImage(imageData: ImageData, options?): Promise<void>`
123
+
124
+ Upload image to device display. Handles resizing, dithering, encoding, and compression automatically.
125
+
126
+ **Parameters:**
127
+ - `imageData`: Image as ImageData from canvas
128
+ - `options.refreshMode`: Display refresh mode (default: `RefreshMode.FULL`)
129
+ - `options.ditherMode`: Dithering algorithm (default: `DitherMode.BURKES`)
130
+ - `options.compress`: Enable zlib compression (default: `true`)
131
+
132
+ ```typescript
133
+ // From canvas
134
+ const canvas = document.getElementById('canvas') as HTMLCanvasElement;
135
+ const ctx = canvas.getContext('2d')!;
136
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
137
+
138
+ await device.uploadImage(imageData, {
139
+ refreshMode: RefreshMode.FULL,
140
+ ditherMode: DitherMode.FLOYD_STEINBERG,
141
+ compress: true
142
+ });
143
+ ```
144
+
145
+ **Supported Refresh Modes:**
146
+ - `RefreshMode.FULL` - Full refresh (recommended, ~15s)
147
+ - `RefreshMode.FAST` - Fast refresh if supported (~2s, may have ghosting)
148
+ - `RefreshMode.PARTIAL` - Partial refresh if supported
149
+
150
+ **Supported Dither Modes** (from [@opendisplay/epaper-dithering](https://www.npmjs.com/package/@opendisplay/epaper-dithering)):
151
+ - `DitherMode.FLOYD_STEINBERG` - Classic error diffusion (recommended)
152
+ - `DitherMode.BURKES` - Burkes error diffusion
153
+ - `DitherMode.SIERRA` - Sierra error diffusion
154
+ - `DitherMode.ATKINSON` - Atkinson dithering (HyperCard style)
155
+ - `DitherMode.STUCKI` - Stucki error diffusion
156
+ - `DitherMode.JARVIS` - Jarvis-Judice-Ninke
157
+ - `DitherMode.SIMPLE_2D` - Fast 2D error diffusion
158
+ - `DitherMode.ORDERED_BAYER_2` - 2x2 Bayer ordered dithering
159
+ - `DitherMode.ORDERED_BAYER_4` - 4x4 Bayer ordered dithering
160
+
161
+ ##### `interrogate(): Promise<GlobalConfig>`
162
+
163
+ Read complete device configuration from device. Automatically called on first connect unless config/capabilities were provided.
164
+
165
+ ```typescript
166
+ const config = await device.interrogate();
167
+ console.log(`Device has ${config.displays.length} display(s)`);
168
+ ```
169
+
170
+ ##### `readFirmwareVersion(): Promise<FirmwareVersion>`
171
+
172
+ Read firmware version from device.
173
+
174
+ ```typescript
175
+ const fw = await device.readFirmwareVersion();
176
+ console.log(`Firmware v${fw.major}.${fw.minor} (${fw.sha})`);
177
+ ```
178
+
179
+ ##### `writeConfig(config: GlobalConfig): Promise<void>`
180
+
181
+ Write configuration to device. Device must be rebooted for changes to take effect.
182
+
183
+ ```typescript
184
+ // Read current config
185
+ const config = device.config!;
186
+
187
+ // Modify config
188
+ config.displays[0].rotation = 1;
189
+
190
+ // Write back to device
191
+ await device.writeConfig(config);
192
+
193
+ // Reboot to apply changes
194
+ await device.reboot();
195
+ ```
196
+
197
+ ##### `reboot(): Promise<void>`
198
+
199
+ Reboot the device. Connection will drop as device resets.
200
+
201
+ ```typescript
202
+ await device.reboot();
203
+ // Device will disconnect automatically
204
+ ```
205
+
206
+ #### Properties
207
+
208
+ ##### `isConnected: boolean`
209
+
210
+ Check if currently connected to a device.
211
+
212
+ ##### `width: number`
213
+
214
+ Display width in pixels (throws if not interrogated).
215
+
216
+ ##### `height: number`
217
+
218
+ Display height in pixels (throws if not interrogated).
219
+
220
+ ##### `colorScheme: ColorScheme`
221
+
222
+ Display color scheme (throws if not interrogated).
223
+
224
+ Possible values:
225
+ - `ColorScheme.MONO` - Black and white
226
+ - `ColorScheme.BWR` - Black, white, red
227
+ - `ColorScheme.BWY` - Black, white, yellow
228
+ - `ColorScheme.BWRY` - Black, white, red, yellow (4-color)
229
+ - `ColorScheme.BWGBRY` - Black, white, green, blue, red, yellow (6-color Spectra)
230
+ - `ColorScheme.GRAYSCALE_4` - 4-level grayscale
231
+
232
+ ##### `rotation: number`
233
+
234
+ Display rotation steps (by 90 degrees) (0, 1, 2, 3).
235
+
236
+ ##### `config: GlobalConfig | null`
237
+
238
+ Full device configuration (null if not interrogated).
239
+
240
+ ##### `capabilities: DeviceCapabilities | null`
241
+
242
+ Minimal device info (width, height, colorScheme, rotation).
243
+
244
+ ### Discovery
245
+
246
+ ```typescript
247
+ import { discoverDevices } from '@opendisplay/opendisplay';
248
+
249
+ // Show device picker
250
+ const device = await discoverDevices();
251
+ // or with name filter
252
+ const device = await discoverDevices('OD');
253
+ ```
254
+
255
+ ### Types
256
+
257
+ All types are exported for TypeScript users:
258
+
259
+ ```typescript
260
+ import type {
261
+ GlobalConfig,
262
+ DisplayConfig,
263
+ DeviceCapabilities,
264
+ FirmwareVersion,
265
+ AdvertisementData
266
+ } from '@opendisplay/opendisplay';
267
+ ```
268
+
269
+ ## Usage Examples
270
+
271
+ ### Basic Image Upload
272
+
273
+ ```typescript
274
+ import { OpenDisplayDevice } from '@opendisplay/opendisplay';
275
+
276
+ const device = new OpenDisplayDevice();
277
+ await device.connect();
278
+
279
+ // Create canvas with image
280
+ const canvas = document.createElement('canvas');
281
+ canvas.width = device.width;
282
+ canvas.height = device.height;
283
+ const ctx = canvas.getContext('2d')!;
284
+
285
+ // Draw something
286
+ ctx.fillStyle = 'white';
287
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
288
+ ctx.fillStyle = 'black';
289
+ ctx.font = '48px Arial';
290
+ ctx.fillText('Hello OpenDisplay!', 50, 100);
291
+
292
+ // Upload to device
293
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
294
+ await device.uploadImage(imageData);
295
+
296
+ await device.disconnect();
297
+ ```
298
+
299
+ ### Upload Image from File
300
+
301
+ ```typescript
302
+ // HTML: <input type="file" id="imageInput" accept="image/*">
303
+
304
+ const input = document.getElementById('imageInput') as HTMLInputElement;
305
+ input.addEventListener('change', async (e) => {
306
+ const file = (e.target as HTMLInputElement).files?.[0];
307
+ if (!file) return;
308
+
309
+ // Load image
310
+ const img = new Image();
311
+ img.src = URL.createObjectURL(file);
312
+ await img.decode();
313
+
314
+ // Convert to ImageData
315
+ const canvas = document.createElement('canvas');
316
+ canvas.width = img.width;
317
+ canvas.height = img.height;
318
+ const ctx = canvas.getContext('2d')!;
319
+ ctx.drawImage(img, 0, 0);
320
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
321
+
322
+ // Upload
323
+ await device.uploadImage(imageData);
324
+ });
325
+ ```
326
+
327
+ ### Skip Interrogation with Cached Config
328
+
329
+ ```typescript
330
+ import { OpenDisplayDevice } from '@opendisplay/opendisplay';
331
+
332
+ // First connection - interrogate and cache
333
+ const device = new OpenDisplayDevice();
334
+ await device.connect();
335
+ const cachedConfig = device.config!;
336
+
337
+ // Store config in localStorage
338
+ localStorage.setItem('deviceConfig', JSON.stringify(cachedConfig));
339
+
340
+ // Later - reuse cached config
341
+ const savedConfig = JSON.parse(localStorage.getItem('deviceConfig')!);
342
+ const fastDevice = new OpenDisplayDevice({ config: savedConfig });
343
+ await fastDevice.connect();
344
+ // No interrogation needed!
345
+ ```
346
+
347
+ ### Skip Interrogation with Minimal Capabilities
348
+
349
+ ```typescript
350
+ import { OpenDisplayDevice, ColorScheme } from '@opendisplay/opendisplay';
351
+
352
+ // If you know your device specs
353
+ const device = new OpenDisplayDevice({
354
+ capabilities: {
355
+ width: 296,
356
+ height: 128,
357
+ colorScheme: ColorScheme.BWR,
358
+ rotation: 0
359
+ }
360
+ });
361
+
362
+ await device.connect();
363
+ // Fast connection - no interrogation!
364
+ ```
365
+
366
+ ### Read and Modify Device Configuration
367
+
368
+ ```typescript
369
+ const device = new OpenDisplayDevice();
370
+ await device.connect();
371
+
372
+ // Read config
373
+ const config = await device.interrogate();
374
+ console.log(`Display: ${config.displays[0].pixelWidth}x${config.displays[0].pixelHeight}`);
375
+ console.log(`Battery: ${config.power?.batteryCapacityMah}mAh`);
376
+
377
+ // Modify rotation
378
+ config.displays[0].rotation = 180;
379
+
380
+ // Write back
381
+ await device.writeConfig(config);
382
+ await device.reboot(); // Reboot to apply
383
+ ```
384
+
385
+ ### Error Handling
386
+
387
+ ```typescript
388
+ import {
389
+ OpenDisplayDevice,
390
+ BLEConnectionError,
391
+ BLETimeoutError,
392
+ ProtocolError
393
+ } from '@opendisplay/opendisplay';
394
+
395
+ try {
396
+ const device = new OpenDisplayDevice();
397
+ await device.connect();
398
+ await device.uploadImage(imageData);
399
+ } catch (error) {
400
+ if (error instanceof BLEConnectionError) {
401
+ console.error('Failed to connect:', error.message);
402
+ } else if (error instanceof BLETimeoutError) {
403
+ console.error('Operation timed out:', error.message);
404
+ } else if (error instanceof ProtocolError) {
405
+ console.error('Protocol error:', error.message);
406
+ } else {
407
+ console.error('Unexpected error:', error);
408
+ }
409
+ }
410
+ ```
411
+
412
+ ## Architecture
413
+
414
+ This library mirrors the architecture of [py-opendisplay](https://github.com/OpenDisplay-org/py-opendisplay):
415
+
416
+ - **Protocol Layer**: Command builders, response parsers, TLV config handling
417
+ - **Transport Layer**: Web Bluetooth wrapper with notification queue
418
+ - **Encoding Layer**: Image encoding, compression, bitplane handling
419
+ - **Models Layer**: TypeScript interfaces for all data structures
420
+ - **Public API**: `OpenDisplayDevice` class and helper functions
421
+
422
+ ## Development
423
+
424
+ ```bash
425
+ # Install dependencies
426
+ npm install
427
+
428
+ # Build library
429
+ npm run build
430
+
431
+ # Type check
432
+ npm run type-check
433
+
434
+ # Lint
435
+ npm run lint
436
+ ```
437
+
438
+ ## Related Packages
439
+
440
+ - [@opendisplay/epaper-dithering](https://www.npmjs.com/package/@opendisplay/epaper-dithering) - Dithering algorithms for e-paper displays
441
+ - [py-opendisplay](https://github.com/OpenDisplay-org/py-opendisplay) - Python version
package/dist/index.cjs CHANGED
@@ -1716,7 +1716,10 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1716
1716
  * await device.uploadImage(imageData, {
1717
1717
  * refreshMode: RefreshMode.FULL,
1718
1718
  * ditherMode: DitherMode.BURKES,
1719
- * compress: true
1719
+ * compress: true,
1720
+ * onProgress: (current, total, stage) => {
1721
+ * console.log(`${stage}: ${current}/${total} (${Math.floor(current/total*100)}%)`);
1722
+ * }
1720
1723
  * });
1721
1724
  * ```
1722
1725
  */
@@ -1726,9 +1729,12 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1726
1729
  const refreshMode = options.refreshMode ?? 0 /* FULL */;
1727
1730
  const ditherMode = options.ditherMode ?? import_epaper_dithering4.DitherMode.BURKES;
1728
1731
  const compress = options.compress ?? true;
1732
+ const onProgress = options.onProgress;
1733
+ const onStatusChange = options.onStatusChange;
1729
1734
  console.log(
1730
1735
  `Uploading image (${this.width}x${this.height}, ${import_epaper_dithering4.ColorScheme[this.colorScheme]})`
1731
1736
  );
1737
+ onStatusChange?.("Preparing image...");
1732
1738
  const encodedData = prepareImageForUpload(
1733
1739
  imageData,
1734
1740
  this.width,
@@ -1738,27 +1744,44 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1738
1744
  );
1739
1745
  let compressedData = null;
1740
1746
  if (compress) {
1747
+ onStatusChange?.("Compressing...");
1741
1748
  compressedData = compressImageData(encodedData, 6);
1742
1749
  if (compressedData.length < MAX_COMPRESSED_SIZE) {
1743
1750
  console.log(`Using compressed upload protocol (size: ${compressedData.length} bytes)`);
1751
+ onStatusChange?.("Uploading...");
1744
1752
  await this.executeUpload({
1745
1753
  imageData: encodedData,
1746
1754
  refreshMode,
1747
1755
  useCompression: true,
1748
1756
  compressedData,
1749
- uncompressedSize: encodedData.length
1757
+ uncompressedSize: encodedData.length,
1758
+ onProgress,
1759
+ onStatusChange
1750
1760
  });
1751
1761
  } else {
1752
1762
  console.log(
1753
1763
  `Compressed size exceeds ${MAX_COMPRESSED_SIZE} bytes, using uncompressed protocol`
1754
1764
  );
1755
- await this.executeUpload({ imageData: encodedData, refreshMode });
1765
+ onStatusChange?.("Uploading...");
1766
+ await this.executeUpload({
1767
+ imageData: encodedData,
1768
+ refreshMode,
1769
+ onProgress,
1770
+ onStatusChange
1771
+ });
1756
1772
  }
1757
1773
  } else {
1758
1774
  console.log("Compression disabled, using uncompressed protocol");
1759
- await this.executeUpload({ imageData: encodedData, refreshMode });
1775
+ onStatusChange?.("Uploading...");
1776
+ await this.executeUpload({
1777
+ imageData: encodedData,
1778
+ refreshMode,
1779
+ onProgress,
1780
+ onStatusChange
1781
+ });
1760
1782
  }
1761
1783
  console.log("Image upload complete");
1784
+ onStatusChange?.("Upload complete!");
1762
1785
  }
1763
1786
  /**
1764
1787
  * Execute image upload using compressed or uncompressed protocol.
@@ -1769,7 +1792,9 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1769
1792
  refreshMode,
1770
1793
  useCompression = false,
1771
1794
  compressedData,
1772
- uncompressedSize
1795
+ uncompressedSize,
1796
+ onProgress,
1797
+ onStatusChange
1773
1798
  } = params;
1774
1799
  let startCmd;
1775
1800
  let remainingCompressed = null;
@@ -1788,11 +1813,12 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1788
1813
  validateAckResponse(response, 112 /* DIRECT_WRITE_START */);
1789
1814
  let autoCompleted = false;
1790
1815
  if (useCompression && remainingCompressed && remainingCompressed.length > 0) {
1791
- autoCompleted = await this.sendDataChunks(remainingCompressed);
1816
+ autoCompleted = await this.sendDataChunks(remainingCompressed, onProgress, onStatusChange);
1792
1817
  } else if (!useCompression) {
1793
- autoCompleted = await this.sendDataChunks(imageData);
1818
+ autoCompleted = await this.sendDataChunks(imageData, onProgress, onStatusChange);
1794
1819
  }
1795
1820
  if (!autoCompleted) {
1821
+ onStatusChange?.("Refreshing display...");
1796
1822
  const endCmd = buildDirectWriteEndCommand(refreshMode);
1797
1823
  await this.connection.writeCommand(endCmd);
1798
1824
  response = await this.connection.readResponse(
@@ -1810,11 +1836,13 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1810
1836
  * - Progress logging
1811
1837
  *
1812
1838
  * @param imageData - Data to send in chunks
1839
+ * @param onProgress - Optional progress callback (current bytes, total bytes, stage)
1840
+ * @param onStatusChange - Optional status message callback
1813
1841
  * @returns True if device auto-completed (sent 0x0072 END early), false if all chunks sent normally
1814
1842
  * @throws {ProtocolError} If unexpected response received
1815
1843
  * @throws {BLETimeoutError} If no response within timeout
1816
1844
  */
1817
- async sendDataChunks(imageData) {
1845
+ async sendDataChunks(imageData, onProgress, onStatusChange) {
1818
1846
  let bytesSent = 0;
1819
1847
  let chunksSent = 0;
1820
1848
  while (bytesSent < imageData.length) {
@@ -1835,6 +1863,7 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1835
1863
  console.log(
1836
1864
  `No response after chunk ${chunksSent} (${(bytesSent / imageData.length * 100).toFixed(1)}%), waiting for device refresh...`
1837
1865
  );
1866
+ onStatusChange?.("Refreshing display...");
1838
1867
  response = await this.connection.readResponse(
1839
1868
  _OpenDisplayDevice.TIMEOUT_REFRESH
1840
1869
  );
@@ -1844,10 +1873,12 @@ var OpenDisplayDevice = class _OpenDisplayDevice {
1844
1873
  }
1845
1874
  const [command, isAck] = checkResponseType(response);
1846
1875
  if (command === 113 /* DIRECT_WRITE_DATA */) {
1876
+ onProgress?.(bytesSent, imageData.length, "upload");
1847
1877
  } else if (command === 114 /* DIRECT_WRITE_END */) {
1848
1878
  console.log(
1849
1879
  `Received END response after chunk ${chunksSent} - device auto-completed`
1850
1880
  );
1881
+ onProgress?.(imageData.length, imageData.length, "upload");
1851
1882
  return true;
1852
1883
  } else {
1853
1884
  throw new ProtocolError(