@opendisplay/opendisplay 1.0.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/index.cjs +1965 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +780 -0
- package/dist/index.d.ts +780 -0
- package/dist/index.js +1936 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1965 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
BLEConnectionError: () => BLEConnectionError,
|
|
34
|
+
BLETimeoutError: () => BLETimeoutError,
|
|
35
|
+
BinaryInputs: () => BinaryInputs,
|
|
36
|
+
BusType: () => BusType,
|
|
37
|
+
ColorScheme: () => import_epaper_dithering5.ColorScheme,
|
|
38
|
+
ConfigParseError: () => ConfigParseError,
|
|
39
|
+
DataBus: () => DataBus,
|
|
40
|
+
DisplayConfig: () => DisplayConfig,
|
|
41
|
+
DitherMode: () => import_epaper_dithering5.DitherMode,
|
|
42
|
+
ICType: () => ICType,
|
|
43
|
+
ImageEncodingError: () => ImageEncodingError,
|
|
44
|
+
InvalidResponseError: () => InvalidResponseError,
|
|
45
|
+
LedConfig: () => LedConfig,
|
|
46
|
+
ManufacturerData: () => ManufacturerData,
|
|
47
|
+
OpenDisplayDevice: () => OpenDisplayDevice,
|
|
48
|
+
OpenDisplayError: () => OpenDisplayError,
|
|
49
|
+
PowerMode: () => PowerMode,
|
|
50
|
+
PowerOption: () => PowerOption,
|
|
51
|
+
ProtocolError: () => ProtocolError,
|
|
52
|
+
RefreshMode: () => RefreshMode,
|
|
53
|
+
Rotation: () => Rotation,
|
|
54
|
+
SensorData: () => SensorData,
|
|
55
|
+
SystemConfig: () => SystemConfig,
|
|
56
|
+
discoverDevices: () => discoverDevices,
|
|
57
|
+
parseAdvertisement: () => parseAdvertisement
|
|
58
|
+
});
|
|
59
|
+
module.exports = __toCommonJS(index_exports);
|
|
60
|
+
|
|
61
|
+
// src/device.ts
|
|
62
|
+
var import_epaper_dithering4 = require("@opendisplay/epaper-dithering");
|
|
63
|
+
|
|
64
|
+
// src/encoding/images.ts
|
|
65
|
+
var import_epaper_dithering2 = require("@opendisplay/epaper-dithering");
|
|
66
|
+
|
|
67
|
+
// src/exceptions.ts
|
|
68
|
+
var OpenDisplayError = class extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "OpenDisplayError";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var BLEConnectionError = class extends OpenDisplayError {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = "BLEConnectionError";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var BLETimeoutError = class extends OpenDisplayError {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "BLETimeoutError";
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var ProtocolError = class extends OpenDisplayError {
|
|
87
|
+
constructor(message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "ProtocolError";
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var ConfigParseError = class extends ProtocolError {
|
|
93
|
+
constructor(message) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "ConfigParseError";
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var InvalidResponseError = class extends ProtocolError {
|
|
99
|
+
constructor(message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "InvalidResponseError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var ImageEncodingError = class extends OpenDisplayError {
|
|
105
|
+
constructor(message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = "ImageEncodingError";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// src/encoding/bitplanes.ts
|
|
112
|
+
var import_epaper_dithering = require("@opendisplay/epaper-dithering");
|
|
113
|
+
function encodeBitplanes(paletteImage, colorScheme) {
|
|
114
|
+
if (colorScheme !== import_epaper_dithering.ColorScheme.BWR && colorScheme !== import_epaper_dithering.ColorScheme.BWY) {
|
|
115
|
+
throw new ImageEncodingError(
|
|
116
|
+
`Bitplane encoding only supports BWR/BWY, got ${import_epaper_dithering.ColorScheme[colorScheme]}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const { width, height, indices: pixels } = paletteImage;
|
|
120
|
+
const bytesPerRow = Math.ceil(width / 8);
|
|
121
|
+
const plane1 = new Uint8Array(bytesPerRow * height);
|
|
122
|
+
const plane2 = new Uint8Array(bytesPerRow * height);
|
|
123
|
+
for (let y = 0; y < height; y++) {
|
|
124
|
+
for (let x = 0; x < width; x++) {
|
|
125
|
+
const byteIdx = y * bytesPerRow + Math.floor(x / 8);
|
|
126
|
+
const bitIdx = 7 - x % 8;
|
|
127
|
+
const paletteIdx = pixels[y * width + x];
|
|
128
|
+
if (paletteIdx === 1) {
|
|
129
|
+
plane1[byteIdx] |= 1 << bitIdx;
|
|
130
|
+
} else if (paletteIdx === 2) {
|
|
131
|
+
plane2[byteIdx] |= 1 << bitIdx;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [plane1, plane2];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/encoding/images.ts
|
|
139
|
+
function encodeImage(paletteImage, colorScheme) {
|
|
140
|
+
switch (colorScheme) {
|
|
141
|
+
case import_epaper_dithering2.ColorScheme.MONO:
|
|
142
|
+
return encode1bpp(paletteImage);
|
|
143
|
+
case import_epaper_dithering2.ColorScheme.BWR:
|
|
144
|
+
case import_epaper_dithering2.ColorScheme.BWY:
|
|
145
|
+
throw new ImageEncodingError(
|
|
146
|
+
`Color scheme ${import_epaper_dithering2.ColorScheme[colorScheme]} requires bitplane encoding, use encodeBitplanes() instead`
|
|
147
|
+
);
|
|
148
|
+
case import_epaper_dithering2.ColorScheme.BWRY:
|
|
149
|
+
return encode2bpp(paletteImage);
|
|
150
|
+
case import_epaper_dithering2.ColorScheme.BWGBRY:
|
|
151
|
+
return encode4bpp(paletteImage, true);
|
|
152
|
+
case import_epaper_dithering2.ColorScheme.GRAYSCALE_4:
|
|
153
|
+
return encode2bpp(paletteImage);
|
|
154
|
+
default:
|
|
155
|
+
throw new ImageEncodingError(
|
|
156
|
+
`Unsupported color scheme: ${import_epaper_dithering2.ColorScheme[colorScheme]}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function encode1bpp(paletteImage) {
|
|
161
|
+
const { width, height, indices: pixels } = paletteImage;
|
|
162
|
+
const bytesPerRow = Math.ceil(width / 8);
|
|
163
|
+
const output = new Uint8Array(bytesPerRow * height);
|
|
164
|
+
for (let y = 0; y < height; y++) {
|
|
165
|
+
for (let x = 0; x < width; x++) {
|
|
166
|
+
const byteIdx = y * bytesPerRow + Math.floor(x / 8);
|
|
167
|
+
const bitIdx = 7 - x % 8;
|
|
168
|
+
if (pixels[y * width + x] > 0) {
|
|
169
|
+
output[byteIdx] |= 1 << bitIdx;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return output;
|
|
174
|
+
}
|
|
175
|
+
function encode2bpp(paletteImage) {
|
|
176
|
+
const { width, height, indices: pixels } = paletteImage;
|
|
177
|
+
const bytesPerRow = Math.ceil(width / 4);
|
|
178
|
+
const output = new Uint8Array(bytesPerRow * height);
|
|
179
|
+
for (let y = 0; y < height; y++) {
|
|
180
|
+
for (let x = 0; x < width; x++) {
|
|
181
|
+
const byteIdx = y * bytesPerRow + Math.floor(x / 4);
|
|
182
|
+
const pixelInByte = x % 4;
|
|
183
|
+
const bitShift = (3 - pixelInByte) * 2;
|
|
184
|
+
const paletteIdx = pixels[y * width + x] & 3;
|
|
185
|
+
output[byteIdx] |= paletteIdx << bitShift;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return output;
|
|
189
|
+
}
|
|
190
|
+
function encode4bpp(paletteImage, bwgbryMapping = false) {
|
|
191
|
+
const { width, height, indices: pixels } = paletteImage;
|
|
192
|
+
const BWGBRY_MAP = { 0: 0, 1: 1, 2: 2, 3: 3, 4: 5, 5: 6 };
|
|
193
|
+
const bytesPerRow = Math.ceil(width / 2);
|
|
194
|
+
const output = new Uint8Array(bytesPerRow * height);
|
|
195
|
+
for (let y = 0; y < height; y++) {
|
|
196
|
+
for (let x = 0; x < width; x++) {
|
|
197
|
+
const byteIdx = y * bytesPerRow + Math.floor(x / 2);
|
|
198
|
+
const pixelInByte = x % 2;
|
|
199
|
+
let paletteIdx = pixels[y * width + x] & 15;
|
|
200
|
+
if (bwgbryMapping && paletteIdx in BWGBRY_MAP) {
|
|
201
|
+
paletteIdx = BWGBRY_MAP[paletteIdx];
|
|
202
|
+
}
|
|
203
|
+
const bitShift = (1 - pixelInByte) * 4;
|
|
204
|
+
output[byteIdx] |= paletteIdx << bitShift;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return output;
|
|
208
|
+
}
|
|
209
|
+
function prepareImageForUpload(imageData, targetWidth, targetHeight, colorScheme, ditherMode = import_epaper_dithering2.DitherMode.BURKES) {
|
|
210
|
+
let resizedImageData = imageData;
|
|
211
|
+
if (imageData.width !== targetWidth || imageData.height !== targetHeight) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`Resizing image from ${imageData.width}x${imageData.height} to ${targetWidth}x${targetHeight}`
|
|
214
|
+
);
|
|
215
|
+
resizedImageData = resizeImageData(imageData, targetWidth, targetHeight);
|
|
216
|
+
}
|
|
217
|
+
const paletteImage = (0, import_epaper_dithering2.ditherImage)(resizedImageData, colorScheme, ditherMode);
|
|
218
|
+
if (colorScheme === import_epaper_dithering2.ColorScheme.BWR || colorScheme === import_epaper_dithering2.ColorScheme.BWY) {
|
|
219
|
+
const [plane1, plane2] = encodeBitplanes(paletteImage, colorScheme);
|
|
220
|
+
const result = new Uint8Array(plane1.length + plane2.length);
|
|
221
|
+
result.set(plane1, 0);
|
|
222
|
+
result.set(plane2, plane1.length);
|
|
223
|
+
return result;
|
|
224
|
+
} else {
|
|
225
|
+
return encodeImage(paletteImage, colorScheme);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function resizeImageData(imageData, targetWidth, targetHeight) {
|
|
229
|
+
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
|
|
230
|
+
const ctx = canvas.getContext("2d");
|
|
231
|
+
if (!ctx) {
|
|
232
|
+
throw new ImageEncodingError("Failed to get canvas context");
|
|
233
|
+
}
|
|
234
|
+
ctx.putImageData(imageData, 0, 0);
|
|
235
|
+
const targetCanvas = new OffscreenCanvas(targetWidth, targetHeight);
|
|
236
|
+
const targetCtx = targetCanvas.getContext("2d");
|
|
237
|
+
if (!targetCtx) {
|
|
238
|
+
throw new ImageEncodingError("Failed to get target canvas context");
|
|
239
|
+
}
|
|
240
|
+
targetCtx.drawImage(canvas, 0, 0, targetWidth, targetHeight);
|
|
241
|
+
return targetCtx.getImageData(0, 0, targetWidth, targetHeight);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/encoding/compression.ts
|
|
245
|
+
var import_pako = __toESM(require("pako"), 1);
|
|
246
|
+
function compressImageData(data, level = 6) {
|
|
247
|
+
if (level === 0) {
|
|
248
|
+
return data;
|
|
249
|
+
}
|
|
250
|
+
const compressed = import_pako.default.deflate(data, { level });
|
|
251
|
+
const ratio = data.length > 0 ? compressed.length / data.length * 100 : 0;
|
|
252
|
+
console.debug(
|
|
253
|
+
`Compressed ${data.length} bytes -> ${compressed.length} bytes (${ratio.toFixed(1)}%)`
|
|
254
|
+
);
|
|
255
|
+
return compressed;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/models/enums.ts
|
|
259
|
+
var RefreshMode = /* @__PURE__ */ ((RefreshMode2) => {
|
|
260
|
+
RefreshMode2[RefreshMode2["FULL"] = 0] = "FULL";
|
|
261
|
+
RefreshMode2[RefreshMode2["FAST"] = 1] = "FAST";
|
|
262
|
+
RefreshMode2[RefreshMode2["PARTIAL"] = 2] = "PARTIAL";
|
|
263
|
+
RefreshMode2[RefreshMode2["PARTIAL2"] = 3] = "PARTIAL2";
|
|
264
|
+
return RefreshMode2;
|
|
265
|
+
})(RefreshMode || {});
|
|
266
|
+
var ICType = /* @__PURE__ */ ((ICType2) => {
|
|
267
|
+
ICType2[ICType2["NRF52840"] = 1] = "NRF52840";
|
|
268
|
+
ICType2[ICType2["ESP32_S3"] = 2] = "ESP32_S3";
|
|
269
|
+
ICType2[ICType2["ESP32_C3"] = 3] = "ESP32_C3";
|
|
270
|
+
ICType2[ICType2["ESP32_C6"] = 4] = "ESP32_C6";
|
|
271
|
+
return ICType2;
|
|
272
|
+
})(ICType || {});
|
|
273
|
+
var PowerMode = /* @__PURE__ */ ((PowerMode2) => {
|
|
274
|
+
PowerMode2[PowerMode2["BATTERY"] = 1] = "BATTERY";
|
|
275
|
+
PowerMode2[PowerMode2["USB"] = 2] = "USB";
|
|
276
|
+
PowerMode2[PowerMode2["SOLAR"] = 3] = "SOLAR";
|
|
277
|
+
return PowerMode2;
|
|
278
|
+
})(PowerMode || {});
|
|
279
|
+
var BusType = /* @__PURE__ */ ((BusType2) => {
|
|
280
|
+
BusType2[BusType2["I2C"] = 0] = "I2C";
|
|
281
|
+
BusType2[BusType2["SPI"] = 1] = "SPI";
|
|
282
|
+
return BusType2;
|
|
283
|
+
})(BusType || {});
|
|
284
|
+
var Rotation = /* @__PURE__ */ ((Rotation2) => {
|
|
285
|
+
Rotation2[Rotation2["ROTATE_0"] = 0] = "ROTATE_0";
|
|
286
|
+
Rotation2[Rotation2["ROTATE_90"] = 90] = "ROTATE_90";
|
|
287
|
+
Rotation2[Rotation2["ROTATE_180"] = 180] = "ROTATE_180";
|
|
288
|
+
Rotation2[Rotation2["ROTATE_270"] = 270] = "ROTATE_270";
|
|
289
|
+
return Rotation2;
|
|
290
|
+
})(Rotation || {});
|
|
291
|
+
|
|
292
|
+
// src/protocol/constants.ts
|
|
293
|
+
var SERVICE_UUID = "00002446-0000-1000-8000-00805f9b34fb";
|
|
294
|
+
var MANUFACTURER_ID = 9286;
|
|
295
|
+
var RESPONSE_HIGH_BIT_FLAG = 32768;
|
|
296
|
+
var CHUNK_SIZE = 230;
|
|
297
|
+
var CONFIG_CHUNK_SIZE = 200;
|
|
298
|
+
var MAX_COMPRESSED_SIZE = 50 * 1024;
|
|
299
|
+
var MAX_START_PAYLOAD = 200;
|
|
300
|
+
var CommandCode = /* @__PURE__ */ ((CommandCode3) => {
|
|
301
|
+
CommandCode3[CommandCode3["READ_CONFIG"] = 64] = "READ_CONFIG";
|
|
302
|
+
CommandCode3[CommandCode3["WRITE_CONFIG"] = 65] = "WRITE_CONFIG";
|
|
303
|
+
CommandCode3[CommandCode3["WRITE_CONFIG_CHUNK"] = 66] = "WRITE_CONFIG_CHUNK";
|
|
304
|
+
CommandCode3[CommandCode3["READ_FW_VERSION"] = 67] = "READ_FW_VERSION";
|
|
305
|
+
CommandCode3[CommandCode3["REBOOT"] = 15] = "REBOOT";
|
|
306
|
+
CommandCode3[CommandCode3["DIRECT_WRITE_START"] = 112] = "DIRECT_WRITE_START";
|
|
307
|
+
CommandCode3[CommandCode3["DIRECT_WRITE_DATA"] = 113] = "DIRECT_WRITE_DATA";
|
|
308
|
+
CommandCode3[CommandCode3["DIRECT_WRITE_END"] = 114] = "DIRECT_WRITE_END";
|
|
309
|
+
return CommandCode3;
|
|
310
|
+
})(CommandCode || {});
|
|
311
|
+
|
|
312
|
+
// src/protocol/commands.ts
|
|
313
|
+
function buildReadConfigCommand() {
|
|
314
|
+
const buffer = new ArrayBuffer(2);
|
|
315
|
+
const view = new DataView(buffer);
|
|
316
|
+
view.setUint16(0, 64 /* READ_CONFIG */, false);
|
|
317
|
+
return new Uint8Array(buffer);
|
|
318
|
+
}
|
|
319
|
+
function buildReadFwVersionCommand() {
|
|
320
|
+
const buffer = new ArrayBuffer(2);
|
|
321
|
+
const view = new DataView(buffer);
|
|
322
|
+
view.setUint16(0, 67 /* READ_FW_VERSION */, false);
|
|
323
|
+
return new Uint8Array(buffer);
|
|
324
|
+
}
|
|
325
|
+
function buildRebootCommand() {
|
|
326
|
+
const buffer = new ArrayBuffer(2);
|
|
327
|
+
const view = new DataView(buffer);
|
|
328
|
+
view.setUint16(0, 15 /* REBOOT */, false);
|
|
329
|
+
return new Uint8Array(buffer);
|
|
330
|
+
}
|
|
331
|
+
function buildDirectWriteStartCompressed(uncompressedSize, compressedData) {
|
|
332
|
+
const maxDataInStart = MAX_START_PAYLOAD - 6;
|
|
333
|
+
const headerSize = 6;
|
|
334
|
+
const totalSize = compressedData.length <= maxDataInStart ? headerSize + compressedData.length : MAX_START_PAYLOAD;
|
|
335
|
+
const buffer = new ArrayBuffer(totalSize);
|
|
336
|
+
const view = new DataView(buffer);
|
|
337
|
+
view.setUint16(0, 112 /* DIRECT_WRITE_START */, false);
|
|
338
|
+
view.setUint32(2, uncompressedSize, true);
|
|
339
|
+
const startCommand = new Uint8Array(buffer);
|
|
340
|
+
const dataLength = Math.min(compressedData.length, maxDataInStart);
|
|
341
|
+
startCommand.set(compressedData.subarray(0, dataLength), 6);
|
|
342
|
+
const remainingData = compressedData.length <= maxDataInStart ? new Uint8Array(0) : compressedData.subarray(maxDataInStart);
|
|
343
|
+
return [startCommand, remainingData];
|
|
344
|
+
}
|
|
345
|
+
function buildDirectWriteStartUncompressed() {
|
|
346
|
+
const buffer = new ArrayBuffer(2);
|
|
347
|
+
const view = new DataView(buffer);
|
|
348
|
+
view.setUint16(0, 112 /* DIRECT_WRITE_START */, false);
|
|
349
|
+
return new Uint8Array(buffer);
|
|
350
|
+
}
|
|
351
|
+
function buildDirectWriteDataCommand(chunkData) {
|
|
352
|
+
if (chunkData.length > CHUNK_SIZE) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Chunk size ${chunkData.length} exceeds maximum ${CHUNK_SIZE}`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const buffer = new ArrayBuffer(2 + chunkData.length);
|
|
358
|
+
const view = new DataView(buffer);
|
|
359
|
+
view.setUint16(0, 113 /* DIRECT_WRITE_DATA */, false);
|
|
360
|
+
const result = new Uint8Array(buffer);
|
|
361
|
+
result.set(chunkData, 2);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
function buildDirectWriteEndCommand(refreshMode = 0) {
|
|
365
|
+
const buffer = new ArrayBuffer(3);
|
|
366
|
+
const view = new DataView(buffer);
|
|
367
|
+
view.setUint16(0, 114 /* DIRECT_WRITE_END */, false);
|
|
368
|
+
view.setUint8(2, refreshMode);
|
|
369
|
+
return new Uint8Array(buffer);
|
|
370
|
+
}
|
|
371
|
+
function buildWriteConfigCommand(configData) {
|
|
372
|
+
const configLen = configData.length;
|
|
373
|
+
if (configLen <= CONFIG_CHUNK_SIZE) {
|
|
374
|
+
const buffer = new ArrayBuffer(2 + configLen);
|
|
375
|
+
const view = new DataView(buffer);
|
|
376
|
+
view.setUint16(0, 65 /* WRITE_CONFIG */, false);
|
|
377
|
+
const result = new Uint8Array(buffer);
|
|
378
|
+
result.set(configData, 2);
|
|
379
|
+
return [result, []];
|
|
380
|
+
}
|
|
381
|
+
const firstChunkDataSize = CONFIG_CHUNK_SIZE - 2;
|
|
382
|
+
const firstBuffer = new ArrayBuffer(2 + 2 + firstChunkDataSize);
|
|
383
|
+
const firstView = new DataView(firstBuffer);
|
|
384
|
+
firstView.setUint16(0, 65 /* WRITE_CONFIG */, false);
|
|
385
|
+
firstView.setUint16(2, configLen, true);
|
|
386
|
+
const firstCommand = new Uint8Array(firstBuffer);
|
|
387
|
+
firstCommand.set(configData.subarray(0, firstChunkDataSize), 4);
|
|
388
|
+
const chunks = [];
|
|
389
|
+
let offset = firstChunkDataSize;
|
|
390
|
+
while (offset < configLen) {
|
|
391
|
+
const chunkSize = Math.min(CONFIG_CHUNK_SIZE, configLen - offset);
|
|
392
|
+
const buffer = new ArrayBuffer(2 + chunkSize);
|
|
393
|
+
const view = new DataView(buffer);
|
|
394
|
+
view.setUint16(0, 66 /* WRITE_CONFIG_CHUNK */, false);
|
|
395
|
+
const chunk = new Uint8Array(buffer);
|
|
396
|
+
chunk.set(configData.subarray(offset, offset + chunkSize), 2);
|
|
397
|
+
chunks.push(chunk);
|
|
398
|
+
offset += chunkSize;
|
|
399
|
+
}
|
|
400
|
+
return [firstCommand, chunks];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/protocol/responses.ts
|
|
404
|
+
function unpackCommandCode(data, offset = 0) {
|
|
405
|
+
const view = new DataView(data.buffer, data.byteOffset + offset, 2);
|
|
406
|
+
return view.getUint16(0, false);
|
|
407
|
+
}
|
|
408
|
+
function stripCommandEcho(data, expectedCmd) {
|
|
409
|
+
if (data.length >= 2) {
|
|
410
|
+
const echo = unpackCommandCode(data);
|
|
411
|
+
if (echo === expectedCmd || echo === (expectedCmd | RESPONSE_HIGH_BIT_FLAG)) {
|
|
412
|
+
return data.subarray(2);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return data;
|
|
416
|
+
}
|
|
417
|
+
function checkResponseType(response) {
|
|
418
|
+
const code = unpackCommandCode(response);
|
|
419
|
+
const isAck = Boolean(code & RESPONSE_HIGH_BIT_FLAG);
|
|
420
|
+
const command = code & ~RESPONSE_HIGH_BIT_FLAG;
|
|
421
|
+
return [command, isAck];
|
|
422
|
+
}
|
|
423
|
+
function validateAckResponse(data, expectedCommand) {
|
|
424
|
+
if (data.length < 2) {
|
|
425
|
+
throw new InvalidResponseError(
|
|
426
|
+
`ACK too short: ${data.length} bytes (need at least 2)`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const responseCode = unpackCommandCode(data);
|
|
430
|
+
const validResponses = /* @__PURE__ */ new Set([
|
|
431
|
+
expectedCommand,
|
|
432
|
+
expectedCommand | RESPONSE_HIGH_BIT_FLAG
|
|
433
|
+
]);
|
|
434
|
+
if (!validResponses.has(responseCode)) {
|
|
435
|
+
throw new InvalidResponseError(
|
|
436
|
+
`ACK mismatch: expected 0x${expectedCommand.toString(16).padStart(4, "0")}, got 0x${responseCode.toString(16).padStart(4, "0")}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function parseFirmwareVersion(data) {
|
|
441
|
+
if (data.length < 5) {
|
|
442
|
+
throw new InvalidResponseError(
|
|
443
|
+
`Firmware version response too short: ${data.length} bytes (need at least 5)`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const echo = unpackCommandCode(data);
|
|
447
|
+
if (echo !== 67 && echo !== (67 | RESPONSE_HIGH_BIT_FLAG)) {
|
|
448
|
+
throw new InvalidResponseError(
|
|
449
|
+
`Firmware version echo mismatch: expected 0x0043, got 0x${echo.toString(16).padStart(4, "0")}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const major = data[2];
|
|
453
|
+
const minor = data[3];
|
|
454
|
+
const shaLength = data[4];
|
|
455
|
+
if (shaLength === 0) {
|
|
456
|
+
throw new InvalidResponseError(
|
|
457
|
+
"Firmware version missing SHA hash (shaLength is 0)"
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const expectedTotalLength = 5 + shaLength;
|
|
461
|
+
if (data.length < expectedTotalLength) {
|
|
462
|
+
throw new InvalidResponseError(
|
|
463
|
+
`Firmware version response incomplete: expected ${expectedTotalLength} bytes (5 header + ${shaLength} SHA), got ${data.length}`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const shaBytes = data.subarray(5, 5 + shaLength);
|
|
467
|
+
const textDecoder = new TextDecoder("ascii");
|
|
468
|
+
let sha;
|
|
469
|
+
try {
|
|
470
|
+
sha = textDecoder.decode(shaBytes);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
throw new InvalidResponseError(
|
|
473
|
+
`Invalid SHA hash encoding (expected ASCII): ${e}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
major,
|
|
478
|
+
minor,
|
|
479
|
+
sha
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/models/config.ts
|
|
484
|
+
var import_epaper_dithering3 = require("@opendisplay/epaper-dithering");
|
|
485
|
+
var SystemConfig;
|
|
486
|
+
((SystemConfig2) => {
|
|
487
|
+
SystemConfig2.SIZE = 22;
|
|
488
|
+
function hasPwrPin(config) {
|
|
489
|
+
return !!(config.deviceFlags & 1);
|
|
490
|
+
}
|
|
491
|
+
SystemConfig2.hasPwrPin = hasPwrPin;
|
|
492
|
+
function needsXiaoinit(config) {
|
|
493
|
+
return !!(config.deviceFlags & 2);
|
|
494
|
+
}
|
|
495
|
+
SystemConfig2.needsXiaoinit = needsXiaoinit;
|
|
496
|
+
function icTypeEnum(config) {
|
|
497
|
+
if (Object.values(ICType).includes(config.icType)) {
|
|
498
|
+
return config.icType;
|
|
499
|
+
}
|
|
500
|
+
return config.icType;
|
|
501
|
+
}
|
|
502
|
+
SystemConfig2.icTypeEnum = icTypeEnum;
|
|
503
|
+
function fromBytes(data) {
|
|
504
|
+
if (data.length < SystemConfig2.SIZE) {
|
|
505
|
+
throw new Error(`Invalid SystemConfig size: ${data.length} < ${SystemConfig2.SIZE}`);
|
|
506
|
+
}
|
|
507
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
508
|
+
return {
|
|
509
|
+
icType: view.getUint16(0, true),
|
|
510
|
+
// little-endian
|
|
511
|
+
communicationModes: view.getUint8(2),
|
|
512
|
+
deviceFlags: view.getUint8(3),
|
|
513
|
+
pwrPin: view.getUint8(4),
|
|
514
|
+
reserved: data.slice(5, 22)
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
SystemConfig2.fromBytes = fromBytes;
|
|
518
|
+
})(SystemConfig || (SystemConfig = {}));
|
|
519
|
+
var ManufacturerData;
|
|
520
|
+
((ManufacturerData2) => {
|
|
521
|
+
ManufacturerData2.SIZE = 22;
|
|
522
|
+
function fromBytes(data) {
|
|
523
|
+
if (data.length < ManufacturerData2.SIZE) {
|
|
524
|
+
throw new Error(`Invalid ManufacturerData size: ${data.length} < ${ManufacturerData2.SIZE}`);
|
|
525
|
+
}
|
|
526
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
527
|
+
return {
|
|
528
|
+
manufacturerId: view.getUint16(0, true),
|
|
529
|
+
// little-endian
|
|
530
|
+
boardType: view.getUint8(2),
|
|
531
|
+
boardRevision: view.getUint8(3),
|
|
532
|
+
reserved: data.slice(4, 22)
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
ManufacturerData2.fromBytes = fromBytes;
|
|
536
|
+
})(ManufacturerData || (ManufacturerData = {}));
|
|
537
|
+
var PowerOption;
|
|
538
|
+
((PowerOption2) => {
|
|
539
|
+
PowerOption2.SIZE = 30;
|
|
540
|
+
function batteryMah(config) {
|
|
541
|
+
return config.batteryCapacityMah;
|
|
542
|
+
}
|
|
543
|
+
PowerOption2.batteryMah = batteryMah;
|
|
544
|
+
function powerModeEnum(config) {
|
|
545
|
+
if (Object.values(PowerMode).includes(config.powerMode)) {
|
|
546
|
+
return config.powerMode;
|
|
547
|
+
}
|
|
548
|
+
return config.powerMode;
|
|
549
|
+
}
|
|
550
|
+
PowerOption2.powerModeEnum = powerModeEnum;
|
|
551
|
+
function fromBytes(data) {
|
|
552
|
+
if (data.length < PowerOption2.SIZE) {
|
|
553
|
+
throw new Error(`Invalid PowerOption size: ${data.length} < ${PowerOption2.SIZE}`);
|
|
554
|
+
}
|
|
555
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
556
|
+
const batteryMah2 = data[1] | data[2] << 8 | data[3] << 16;
|
|
557
|
+
return {
|
|
558
|
+
powerMode: view.getUint8(0),
|
|
559
|
+
batteryCapacityMah: batteryMah2,
|
|
560
|
+
sleepTimeoutMs: view.getUint16(4, true),
|
|
561
|
+
// little-endian
|
|
562
|
+
txPower: view.getInt8(6),
|
|
563
|
+
sleepFlags: view.getUint8(7),
|
|
564
|
+
batterySensePin: view.getUint8(8),
|
|
565
|
+
batterySenseEnablePin: view.getUint8(9),
|
|
566
|
+
batterySenseFlags: view.getUint8(10),
|
|
567
|
+
capacityEstimator: view.getUint8(11),
|
|
568
|
+
voltageScalingFactor: view.getUint16(12, true),
|
|
569
|
+
// little-endian
|
|
570
|
+
deepSleepCurrentUa: view.getUint32(14, true),
|
|
571
|
+
// little-endian
|
|
572
|
+
deepSleepTimeSeconds: view.getUint16(18, true),
|
|
573
|
+
// little-endian
|
|
574
|
+
reserved: data.slice(20, 30)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
PowerOption2.fromBytes = fromBytes;
|
|
578
|
+
})(PowerOption || (PowerOption = {}));
|
|
579
|
+
var DisplayConfig;
|
|
580
|
+
((DisplayConfig2) => {
|
|
581
|
+
DisplayConfig2.SIZE = 46;
|
|
582
|
+
function supportsRaw(config) {
|
|
583
|
+
return !!(config.transmissionModes & 1);
|
|
584
|
+
}
|
|
585
|
+
DisplayConfig2.supportsRaw = supportsRaw;
|
|
586
|
+
function supportsZip(config) {
|
|
587
|
+
return !!(config.transmissionModes & 2);
|
|
588
|
+
}
|
|
589
|
+
DisplayConfig2.supportsZip = supportsZip;
|
|
590
|
+
function supportsG5(config) {
|
|
591
|
+
return !!(config.transmissionModes & 4);
|
|
592
|
+
}
|
|
593
|
+
DisplayConfig2.supportsG5 = supportsG5;
|
|
594
|
+
function supportsDirectWrite(config) {
|
|
595
|
+
return !!(config.transmissionModes & 8);
|
|
596
|
+
}
|
|
597
|
+
DisplayConfig2.supportsDirectWrite = supportsDirectWrite;
|
|
598
|
+
function clearOnBoot(config) {
|
|
599
|
+
return !!(config.transmissionModes & 128);
|
|
600
|
+
}
|
|
601
|
+
DisplayConfig2.clearOnBoot = clearOnBoot;
|
|
602
|
+
function colorSchemeEnum(config) {
|
|
603
|
+
if (Object.values(import_epaper_dithering3.ColorScheme).includes(config.colorScheme)) {
|
|
604
|
+
return config.colorScheme;
|
|
605
|
+
}
|
|
606
|
+
return config.colorScheme;
|
|
607
|
+
}
|
|
608
|
+
DisplayConfig2.colorSchemeEnum = colorSchemeEnum;
|
|
609
|
+
function rotationEnum(config) {
|
|
610
|
+
if (Object.values(Rotation).includes(config.rotation)) {
|
|
611
|
+
return config.rotation;
|
|
612
|
+
}
|
|
613
|
+
return config.rotation;
|
|
614
|
+
}
|
|
615
|
+
DisplayConfig2.rotationEnum = rotationEnum;
|
|
616
|
+
function fromBytes(data) {
|
|
617
|
+
if (data.length < DisplayConfig2.SIZE) {
|
|
618
|
+
throw new Error(`Invalid DisplayConfig size: ${data.length} < ${DisplayConfig2.SIZE}`);
|
|
619
|
+
}
|
|
620
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
621
|
+
return {
|
|
622
|
+
instanceNumber: view.getUint8(0),
|
|
623
|
+
displayTechnology: view.getUint8(1),
|
|
624
|
+
panelIcType: view.getUint16(2, true),
|
|
625
|
+
// little-endian
|
|
626
|
+
pixelWidth: view.getUint16(4, true),
|
|
627
|
+
// little-endian
|
|
628
|
+
pixelHeight: view.getUint16(6, true),
|
|
629
|
+
// little-endian
|
|
630
|
+
activeWidthMm: view.getUint16(8, true),
|
|
631
|
+
// little-endian
|
|
632
|
+
activeHeightMm: view.getUint16(10, true),
|
|
633
|
+
// little-endian
|
|
634
|
+
tagType: view.getUint16(12, true),
|
|
635
|
+
// little-endian
|
|
636
|
+
rotation: view.getUint8(14),
|
|
637
|
+
resetPin: view.getUint8(15),
|
|
638
|
+
busyPin: view.getUint8(16),
|
|
639
|
+
dcPin: view.getUint8(17),
|
|
640
|
+
csPin: view.getUint8(18),
|
|
641
|
+
dataPin: view.getUint8(19),
|
|
642
|
+
partialUpdateSupport: view.getUint8(20),
|
|
643
|
+
colorScheme: view.getUint8(21),
|
|
644
|
+
transmissionModes: view.getUint8(22),
|
|
645
|
+
clkPin: view.getUint8(23),
|
|
646
|
+
reservedPins: data.slice(24, 31),
|
|
647
|
+
// 7 pins
|
|
648
|
+
reserved: data.slice(31, 46)
|
|
649
|
+
// 15 bytes
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
DisplayConfig2.fromBytes = fromBytes;
|
|
653
|
+
})(DisplayConfig || (DisplayConfig = {}));
|
|
654
|
+
var LedConfig;
|
|
655
|
+
((LedConfig2) => {
|
|
656
|
+
LedConfig2.SIZE = 22;
|
|
657
|
+
function fromBytes(data) {
|
|
658
|
+
if (data.length < LedConfig2.SIZE) {
|
|
659
|
+
throw new Error(`Invalid LedConfig size: ${data.length} < ${LedConfig2.SIZE}`);
|
|
660
|
+
}
|
|
661
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
662
|
+
return {
|
|
663
|
+
instanceNumber: view.getUint8(0),
|
|
664
|
+
ledType: view.getUint8(1),
|
|
665
|
+
led1R: view.getUint8(2),
|
|
666
|
+
led2G: view.getUint8(3),
|
|
667
|
+
led3B: view.getUint8(4),
|
|
668
|
+
led4: view.getUint8(5),
|
|
669
|
+
ledFlags: view.getUint8(6),
|
|
670
|
+
reserved: data.slice(7, 22)
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
LedConfig2.fromBytes = fromBytes;
|
|
674
|
+
})(LedConfig || (LedConfig = {}));
|
|
675
|
+
var SensorData;
|
|
676
|
+
((SensorData2) => {
|
|
677
|
+
SensorData2.SIZE = 30;
|
|
678
|
+
function fromBytes(data) {
|
|
679
|
+
if (data.length < SensorData2.SIZE) {
|
|
680
|
+
throw new Error(`Invalid SensorData size: ${data.length} < ${SensorData2.SIZE}`);
|
|
681
|
+
}
|
|
682
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
683
|
+
return {
|
|
684
|
+
instanceNumber: view.getUint8(0),
|
|
685
|
+
sensorType: view.getUint16(1, true),
|
|
686
|
+
// little-endian
|
|
687
|
+
busId: view.getUint8(3),
|
|
688
|
+
reserved: data.slice(4, 30)
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
SensorData2.fromBytes = fromBytes;
|
|
692
|
+
})(SensorData || (SensorData = {}));
|
|
693
|
+
var DataBus;
|
|
694
|
+
((DataBus2) => {
|
|
695
|
+
DataBus2.SIZE = 30;
|
|
696
|
+
function busTypeEnum(config) {
|
|
697
|
+
if (Object.values(BusType).includes(config.busType)) {
|
|
698
|
+
return config.busType;
|
|
699
|
+
}
|
|
700
|
+
return config.busType;
|
|
701
|
+
}
|
|
702
|
+
DataBus2.busTypeEnum = busTypeEnum;
|
|
703
|
+
function fromBytes(data) {
|
|
704
|
+
if (data.length < DataBus2.SIZE) {
|
|
705
|
+
throw new Error(`Invalid DataBus size: ${data.length} < ${DataBus2.SIZE}`);
|
|
706
|
+
}
|
|
707
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
708
|
+
return {
|
|
709
|
+
instanceNumber: view.getUint8(0),
|
|
710
|
+
busType: view.getUint8(1),
|
|
711
|
+
pin1: view.getUint8(2),
|
|
712
|
+
pin2: view.getUint8(3),
|
|
713
|
+
pin3: view.getUint8(4),
|
|
714
|
+
pin4: view.getUint8(5),
|
|
715
|
+
pin5: view.getUint8(6),
|
|
716
|
+
pin6: view.getUint8(7),
|
|
717
|
+
pin7: view.getUint8(8),
|
|
718
|
+
busSpeedHz: view.getUint32(9, true),
|
|
719
|
+
// little-endian
|
|
720
|
+
busFlags: view.getUint8(13),
|
|
721
|
+
pullups: view.getUint8(14),
|
|
722
|
+
pulldowns: view.getUint8(15),
|
|
723
|
+
reserved: data.slice(16, 30)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
DataBus2.fromBytes = fromBytes;
|
|
727
|
+
})(DataBus || (DataBus = {}));
|
|
728
|
+
var BinaryInputs;
|
|
729
|
+
((BinaryInputs2) => {
|
|
730
|
+
BinaryInputs2.SIZE = 30;
|
|
731
|
+
function fromBytes(data) {
|
|
732
|
+
if (data.length < BinaryInputs2.SIZE) {
|
|
733
|
+
throw new Error(`Invalid BinaryInputs size: ${data.length} < ${BinaryInputs2.SIZE}`);
|
|
734
|
+
}
|
|
735
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
736
|
+
return {
|
|
737
|
+
instanceNumber: view.getUint8(0),
|
|
738
|
+
inputType: view.getUint8(1),
|
|
739
|
+
displayAs: view.getUint8(2),
|
|
740
|
+
reservedPins: data.slice(3, 11),
|
|
741
|
+
// 8 pins
|
|
742
|
+
inputFlags: view.getUint8(11),
|
|
743
|
+
invert: view.getUint8(12),
|
|
744
|
+
pullups: view.getUint8(13),
|
|
745
|
+
pulldowns: view.getUint8(14),
|
|
746
|
+
reserved: data.slice(15, 30)
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
BinaryInputs2.fromBytes = fromBytes;
|
|
750
|
+
})(BinaryInputs || (BinaryInputs = {}));
|
|
751
|
+
|
|
752
|
+
// src/protocol/config-parser.ts
|
|
753
|
+
var PACKET_TYPE_SYSTEM = 1;
|
|
754
|
+
var PACKET_TYPE_MANUFACTURER = 2;
|
|
755
|
+
var PACKET_TYPE_POWER = 4;
|
|
756
|
+
var PACKET_TYPE_DISPLAY = 32;
|
|
757
|
+
var PACKET_TYPE_LED = 33;
|
|
758
|
+
var PACKET_TYPE_SENSOR = 35;
|
|
759
|
+
var PACKET_TYPE_DATABUS = 36;
|
|
760
|
+
var PACKET_TYPE_BINARY_INPUT = 37;
|
|
761
|
+
function parseConfigResponse(rawData) {
|
|
762
|
+
if (rawData.length < 5) {
|
|
763
|
+
throw new ConfigParseError(
|
|
764
|
+
`Config data too short: ${rawData.length} bytes (need at least 5)`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const view = new DataView(rawData.buffer, rawData.byteOffset, rawData.byteLength);
|
|
768
|
+
const configLength = view.getUint16(0, true);
|
|
769
|
+
const configVersion = view.getUint8(2);
|
|
770
|
+
console.debug(
|
|
771
|
+
`TLV wrapper: length=${configLength} bytes, version=${configVersion}`
|
|
772
|
+
);
|
|
773
|
+
let packetData;
|
|
774
|
+
if (rawData.length > 5) {
|
|
775
|
+
packetData = rawData.slice(3, -2);
|
|
776
|
+
} else {
|
|
777
|
+
packetData = rawData.slice(3);
|
|
778
|
+
}
|
|
779
|
+
console.debug(`Packet data after wrapper strip: ${packetData.length} bytes`);
|
|
780
|
+
return parseTlvConfig(packetData, configVersion);
|
|
781
|
+
}
|
|
782
|
+
function parseTlvConfig(data, version = 1) {
|
|
783
|
+
if (data.length < 2) {
|
|
784
|
+
throw new ConfigParseError(
|
|
785
|
+
`TLV data too short: ${data.length} bytes (need at least 2)`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
console.debug(`Parsing TLV config, ${data.length} bytes`);
|
|
789
|
+
let offset = 0;
|
|
790
|
+
const packets = /* @__PURE__ */ new Map();
|
|
791
|
+
while (offset < data.length - 1) {
|
|
792
|
+
if (offset + 2 > data.length) {
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
const packetNumber = data[offset];
|
|
796
|
+
const packetType = data[offset + 1];
|
|
797
|
+
offset += 2;
|
|
798
|
+
const packetSize = getPacketSize(packetType);
|
|
799
|
+
if (packetSize === null) {
|
|
800
|
+
console.warn(
|
|
801
|
+
`Unknown packet type 0x${packetType.toString(16).padStart(2, "0")} at offset ${offset - 2}, skipping`
|
|
802
|
+
);
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
if (offset + packetSize > data.length) {
|
|
806
|
+
throw new ConfigParseError(
|
|
807
|
+
`Packet type 0x${packetType.toString(16).padStart(2, "0")} truncated: need ${packetSize} bytes, have ${data.length - offset}`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
const packetData = data.slice(offset, offset + packetSize);
|
|
811
|
+
offset += packetSize;
|
|
812
|
+
const key = `${packetType}-${packetNumber}`;
|
|
813
|
+
packets.set(key, packetData);
|
|
814
|
+
console.debug(
|
|
815
|
+
`Parsed packet: type=0x${packetType.toString(16).padStart(2, "0")}, num=${packetNumber}, size=${packetSize}`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
let system;
|
|
819
|
+
let manufacturer;
|
|
820
|
+
let power;
|
|
821
|
+
const displays = [];
|
|
822
|
+
const leds = [];
|
|
823
|
+
const sensors = [];
|
|
824
|
+
const dataBuses = [];
|
|
825
|
+
const binaryInputs = [];
|
|
826
|
+
for (const [key, packetData] of packets) {
|
|
827
|
+
const [packetTypeStr] = key.split("-");
|
|
828
|
+
const packetType = parseInt(packetTypeStr, 10);
|
|
829
|
+
switch (packetType) {
|
|
830
|
+
case PACKET_TYPE_SYSTEM:
|
|
831
|
+
system = parseSystemConfig(packetData);
|
|
832
|
+
break;
|
|
833
|
+
case PACKET_TYPE_MANUFACTURER:
|
|
834
|
+
manufacturer = parseManufacturerData(packetData);
|
|
835
|
+
break;
|
|
836
|
+
case PACKET_TYPE_POWER:
|
|
837
|
+
power = parsePowerOption(packetData);
|
|
838
|
+
break;
|
|
839
|
+
case PACKET_TYPE_DISPLAY:
|
|
840
|
+
displays.push(parseDisplayConfig(packetData));
|
|
841
|
+
break;
|
|
842
|
+
case PACKET_TYPE_LED:
|
|
843
|
+
leds.push(parseLedConfig(packetData));
|
|
844
|
+
break;
|
|
845
|
+
case PACKET_TYPE_SENSOR:
|
|
846
|
+
sensors.push(parseSensorData(packetData));
|
|
847
|
+
break;
|
|
848
|
+
case PACKET_TYPE_DATABUS:
|
|
849
|
+
dataBuses.push(parseDataBus(packetData));
|
|
850
|
+
break;
|
|
851
|
+
case PACKET_TYPE_BINARY_INPUT:
|
|
852
|
+
binaryInputs.push(parseBinaryInputs(packetData));
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
system,
|
|
858
|
+
manufacturer,
|
|
859
|
+
power,
|
|
860
|
+
displays,
|
|
861
|
+
leds,
|
|
862
|
+
sensors,
|
|
863
|
+
dataBuses,
|
|
864
|
+
binaryInputs,
|
|
865
|
+
version,
|
|
866
|
+
// From firmware wrapper
|
|
867
|
+
minorVersion: 1,
|
|
868
|
+
// Not stored in device (only single version byte exists)
|
|
869
|
+
loaded: true
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function getPacketSize(packetType) {
|
|
873
|
+
const sizes = {
|
|
874
|
+
[PACKET_TYPE_SYSTEM]: 22,
|
|
875
|
+
[PACKET_TYPE_MANUFACTURER]: 22,
|
|
876
|
+
[PACKET_TYPE_POWER]: 30,
|
|
877
|
+
// Fixed: was 32
|
|
878
|
+
[PACKET_TYPE_DISPLAY]: 46,
|
|
879
|
+
// Fixed: was 66
|
|
880
|
+
[PACKET_TYPE_LED]: 22,
|
|
881
|
+
[PACKET_TYPE_SENSOR]: 30,
|
|
882
|
+
[PACKET_TYPE_DATABUS]: 30,
|
|
883
|
+
// Fixed: was 28
|
|
884
|
+
[PACKET_TYPE_BINARY_INPUT]: 30
|
|
885
|
+
// Fixed: was 29
|
|
886
|
+
};
|
|
887
|
+
return sizes[packetType] ?? null;
|
|
888
|
+
}
|
|
889
|
+
function parseSystemConfig(data) {
|
|
890
|
+
if (data.length < 22) {
|
|
891
|
+
throw new ConfigParseError(
|
|
892
|
+
`SystemConfig too short: ${data.length} bytes (need 22)`
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
return SystemConfig.fromBytes(data);
|
|
896
|
+
}
|
|
897
|
+
function parseManufacturerData(data) {
|
|
898
|
+
if (data.length < 22) {
|
|
899
|
+
throw new ConfigParseError(
|
|
900
|
+
`ManufacturerData too short: ${data.length} bytes (need 22)`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
return ManufacturerData.fromBytes(data);
|
|
904
|
+
}
|
|
905
|
+
function parsePowerOption(data) {
|
|
906
|
+
if (data.length < 30) {
|
|
907
|
+
throw new ConfigParseError(
|
|
908
|
+
`PowerOption too short: ${data.length} bytes (need 30)`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
return PowerOption.fromBytes(data);
|
|
912
|
+
}
|
|
913
|
+
function parseDisplayConfig(data) {
|
|
914
|
+
if (data.length < 46) {
|
|
915
|
+
throw new ConfigParseError(
|
|
916
|
+
`DisplayConfig too short: ${data.length} bytes (need 46)`
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
return DisplayConfig.fromBytes(data);
|
|
920
|
+
}
|
|
921
|
+
function parseLedConfig(data) {
|
|
922
|
+
if (data.length < 22) {
|
|
923
|
+
throw new ConfigParseError(
|
|
924
|
+
`LedConfig too short: ${data.length} bytes (need 22)`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return LedConfig.fromBytes(data);
|
|
928
|
+
}
|
|
929
|
+
function parseSensorData(data) {
|
|
930
|
+
if (data.length < 30) {
|
|
931
|
+
throw new ConfigParseError(
|
|
932
|
+
`SensorData too short: ${data.length} bytes (need 30)`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return SensorData.fromBytes(data);
|
|
936
|
+
}
|
|
937
|
+
function parseDataBus(data) {
|
|
938
|
+
if (data.length < 30) {
|
|
939
|
+
throw new ConfigParseError(
|
|
940
|
+
`DataBus too short: ${data.length} bytes (need 30)`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
return DataBus.fromBytes(data);
|
|
944
|
+
}
|
|
945
|
+
function parseBinaryInputs(data) {
|
|
946
|
+
if (data.length < 30) {
|
|
947
|
+
throw new ConfigParseError(
|
|
948
|
+
`BinaryInputs too short: ${data.length} bytes (need 30)`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return BinaryInputs.fromBytes(data);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/protocol/config-serializer.ts
|
|
955
|
+
var PACKET_TYPE_SYSTEM2 = 1;
|
|
956
|
+
var PACKET_TYPE_MANUFACTURER2 = 2;
|
|
957
|
+
var PACKET_TYPE_POWER2 = 4;
|
|
958
|
+
var PACKET_TYPE_DISPLAY2 = 32;
|
|
959
|
+
var PACKET_TYPE_LED2 = 33;
|
|
960
|
+
var PACKET_TYPE_SENSOR2 = 35;
|
|
961
|
+
var PACKET_TYPE_DATABUS2 = 36;
|
|
962
|
+
var PACKET_TYPE_BINARY_INPUT2 = 37;
|
|
963
|
+
function calculateConfigCrc(data) {
|
|
964
|
+
let crc = 4294967295;
|
|
965
|
+
for (const byte of data) {
|
|
966
|
+
crc ^= byte;
|
|
967
|
+
for (let i = 0; i < 8; i++) {
|
|
968
|
+
if (crc & 1) {
|
|
969
|
+
crc = crc >>> 1 ^ 3988292384;
|
|
970
|
+
} else {
|
|
971
|
+
crc = crc >>> 1;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const crc32 = ~crc >>> 0;
|
|
976
|
+
return crc32 & 65535;
|
|
977
|
+
}
|
|
978
|
+
function serializeSystemConfig(config) {
|
|
979
|
+
const buffer = new ArrayBuffer(22);
|
|
980
|
+
const view = new DataView(buffer);
|
|
981
|
+
const result = new Uint8Array(buffer);
|
|
982
|
+
view.setUint16(0, config.icType, true);
|
|
983
|
+
view.setUint8(2, config.communicationModes);
|
|
984
|
+
view.setUint8(3, config.deviceFlags);
|
|
985
|
+
view.setUint8(4, config.pwrPin);
|
|
986
|
+
const reserved = config.reserved || new Uint8Array(17);
|
|
987
|
+
result.set(reserved.subarray(0, 17), 5);
|
|
988
|
+
return result;
|
|
989
|
+
}
|
|
990
|
+
function serializeManufacturerData(config) {
|
|
991
|
+
const buffer = new ArrayBuffer(22);
|
|
992
|
+
const view = new DataView(buffer);
|
|
993
|
+
const result = new Uint8Array(buffer);
|
|
994
|
+
view.setUint16(0, config.manufacturerId, true);
|
|
995
|
+
view.setUint8(2, config.boardType);
|
|
996
|
+
view.setUint8(3, config.boardRevision);
|
|
997
|
+
const reserved = config.reserved || new Uint8Array(18);
|
|
998
|
+
result.set(reserved.subarray(0, 18), 4);
|
|
999
|
+
return result;
|
|
1000
|
+
}
|
|
1001
|
+
function serializePowerOption(config) {
|
|
1002
|
+
const buffer = new ArrayBuffer(30);
|
|
1003
|
+
const view = new DataView(buffer);
|
|
1004
|
+
const result = new Uint8Array(buffer);
|
|
1005
|
+
view.setUint8(0, config.powerMode);
|
|
1006
|
+
view.setUint8(1, config.batteryCapacityMah & 255);
|
|
1007
|
+
view.setUint8(2, config.batteryCapacityMah >> 8 & 255);
|
|
1008
|
+
view.setUint8(3, config.batteryCapacityMah >> 16 & 255);
|
|
1009
|
+
view.setUint16(4, config.sleepTimeoutMs, true);
|
|
1010
|
+
view.setInt8(6, config.txPower);
|
|
1011
|
+
view.setUint8(7, config.sleepFlags);
|
|
1012
|
+
view.setUint8(8, config.batterySensePin);
|
|
1013
|
+
view.setUint8(9, config.batterySenseEnablePin);
|
|
1014
|
+
view.setUint8(10, config.batterySenseFlags);
|
|
1015
|
+
view.setUint8(11, config.capacityEstimator);
|
|
1016
|
+
view.setUint16(12, config.voltageScalingFactor, true);
|
|
1017
|
+
view.setUint32(14, config.deepSleepCurrentUa, true);
|
|
1018
|
+
view.setUint16(18, config.deepSleepTimeSeconds, true);
|
|
1019
|
+
const reserved = config.reserved || new Uint8Array(10);
|
|
1020
|
+
result.set(reserved.subarray(0, 10), 20);
|
|
1021
|
+
return result;
|
|
1022
|
+
}
|
|
1023
|
+
function serializeDisplayConfig(config) {
|
|
1024
|
+
const buffer = new ArrayBuffer(46);
|
|
1025
|
+
const view = new DataView(buffer);
|
|
1026
|
+
const result = new Uint8Array(buffer);
|
|
1027
|
+
view.setUint8(0, config.instanceNumber);
|
|
1028
|
+
view.setUint8(1, config.displayTechnology);
|
|
1029
|
+
view.setUint16(2, config.panelIcType, true);
|
|
1030
|
+
view.setUint16(4, config.pixelWidth, true);
|
|
1031
|
+
view.setUint16(6, config.pixelHeight, true);
|
|
1032
|
+
view.setUint16(8, config.activeWidthMm, true);
|
|
1033
|
+
view.setUint16(10, config.activeHeightMm, true);
|
|
1034
|
+
view.setUint16(12, config.tagType, true);
|
|
1035
|
+
view.setUint8(14, config.rotation);
|
|
1036
|
+
view.setUint8(15, config.resetPin);
|
|
1037
|
+
view.setUint8(16, config.busyPin);
|
|
1038
|
+
view.setUint8(17, config.dcPin);
|
|
1039
|
+
view.setUint8(18, config.csPin);
|
|
1040
|
+
view.setUint8(19, config.dataPin);
|
|
1041
|
+
view.setUint8(20, config.partialUpdateSupport);
|
|
1042
|
+
view.setUint8(21, config.colorScheme);
|
|
1043
|
+
view.setUint8(22, config.transmissionModes);
|
|
1044
|
+
view.setUint8(23, config.clkPin);
|
|
1045
|
+
const reservedPins = config.reservedPins || new Uint8Array(7).fill(255);
|
|
1046
|
+
result.set(reservedPins.subarray(0, 7), 24);
|
|
1047
|
+
const reserved = config.reserved || new Uint8Array(15);
|
|
1048
|
+
result.set(reserved.subarray(0, 15), 31);
|
|
1049
|
+
return result;
|
|
1050
|
+
}
|
|
1051
|
+
function serializeLedConfig(config) {
|
|
1052
|
+
const buffer = new ArrayBuffer(22);
|
|
1053
|
+
const view = new DataView(buffer);
|
|
1054
|
+
const result = new Uint8Array(buffer);
|
|
1055
|
+
view.setUint8(0, config.instanceNumber);
|
|
1056
|
+
view.setUint8(1, config.ledType);
|
|
1057
|
+
view.setUint8(2, config.led1R);
|
|
1058
|
+
view.setUint8(3, config.led2G);
|
|
1059
|
+
view.setUint8(4, config.led3B);
|
|
1060
|
+
view.setUint8(5, config.led4);
|
|
1061
|
+
view.setUint8(6, config.ledFlags);
|
|
1062
|
+
const reserved = config.reserved || new Uint8Array(15);
|
|
1063
|
+
result.set(reserved.subarray(0, 15), 7);
|
|
1064
|
+
return result;
|
|
1065
|
+
}
|
|
1066
|
+
function serializeSensorData(config) {
|
|
1067
|
+
const buffer = new ArrayBuffer(30);
|
|
1068
|
+
const view = new DataView(buffer);
|
|
1069
|
+
const result = new Uint8Array(buffer);
|
|
1070
|
+
view.setUint8(0, config.instanceNumber);
|
|
1071
|
+
view.setUint16(1, config.sensorType, true);
|
|
1072
|
+
view.setUint8(3, config.busId);
|
|
1073
|
+
const reserved = config.reserved || new Uint8Array(26);
|
|
1074
|
+
result.set(reserved.subarray(0, 26), 4);
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
function serializeDataBus(config) {
|
|
1078
|
+
const buffer = new ArrayBuffer(30);
|
|
1079
|
+
const view = new DataView(buffer);
|
|
1080
|
+
const result = new Uint8Array(buffer);
|
|
1081
|
+
view.setUint8(0, config.instanceNumber);
|
|
1082
|
+
view.setUint8(1, config.busType);
|
|
1083
|
+
view.setUint8(2, config.pin1);
|
|
1084
|
+
view.setUint8(3, config.pin2);
|
|
1085
|
+
view.setUint8(4, config.pin3);
|
|
1086
|
+
view.setUint8(5, config.pin4);
|
|
1087
|
+
view.setUint8(6, config.pin5);
|
|
1088
|
+
view.setUint8(7, config.pin6);
|
|
1089
|
+
view.setUint8(8, config.pin7);
|
|
1090
|
+
view.setUint32(9, config.busSpeedHz, true);
|
|
1091
|
+
view.setUint8(13, config.busFlags);
|
|
1092
|
+
view.setUint8(14, config.pullups);
|
|
1093
|
+
view.setUint8(15, config.pulldowns);
|
|
1094
|
+
const reserved = config.reserved || new Uint8Array(14);
|
|
1095
|
+
result.set(reserved.subarray(0, 14), 16);
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
1098
|
+
function serializeBinaryInputs(config) {
|
|
1099
|
+
const buffer = new ArrayBuffer(30);
|
|
1100
|
+
const view = new DataView(buffer);
|
|
1101
|
+
const result = new Uint8Array(buffer);
|
|
1102
|
+
view.setUint8(0, config.instanceNumber);
|
|
1103
|
+
view.setUint8(1, config.inputType);
|
|
1104
|
+
view.setUint8(2, config.displayAs);
|
|
1105
|
+
const reservedPins = config.reservedPins || new Uint8Array(8);
|
|
1106
|
+
result.set(reservedPins.subarray(0, 8), 3);
|
|
1107
|
+
view.setUint8(11, config.inputFlags);
|
|
1108
|
+
view.setUint8(12, config.invert);
|
|
1109
|
+
view.setUint8(13, config.pullups);
|
|
1110
|
+
view.setUint8(14, config.pulldowns);
|
|
1111
|
+
const reserved = config.reserved || new Uint8Array(15);
|
|
1112
|
+
result.set(reserved.subarray(0, 15), 15);
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
function serializeConfig(config) {
|
|
1116
|
+
const chunks = [];
|
|
1117
|
+
const header = new Uint8Array([0, 0, config.version]);
|
|
1118
|
+
chunks.push(header);
|
|
1119
|
+
if (config.system) {
|
|
1120
|
+
chunks.push(new Uint8Array([0, PACKET_TYPE_SYSTEM2]));
|
|
1121
|
+
chunks.push(serializeSystemConfig(config.system));
|
|
1122
|
+
}
|
|
1123
|
+
if (config.manufacturer) {
|
|
1124
|
+
chunks.push(new Uint8Array([0, PACKET_TYPE_MANUFACTURER2]));
|
|
1125
|
+
chunks.push(serializeManufacturerData(config.manufacturer));
|
|
1126
|
+
}
|
|
1127
|
+
if (config.power) {
|
|
1128
|
+
chunks.push(new Uint8Array([0, PACKET_TYPE_POWER2]));
|
|
1129
|
+
chunks.push(serializePowerOption(config.power));
|
|
1130
|
+
}
|
|
1131
|
+
for (let i = 0; i < Math.min(config.displays.length, 4); i++) {
|
|
1132
|
+
chunks.push(new Uint8Array([i, PACKET_TYPE_DISPLAY2]));
|
|
1133
|
+
chunks.push(serializeDisplayConfig(config.displays[i]));
|
|
1134
|
+
}
|
|
1135
|
+
for (let i = 0; i < Math.min(config.leds.length, 4); i++) {
|
|
1136
|
+
chunks.push(new Uint8Array([i, PACKET_TYPE_LED2]));
|
|
1137
|
+
chunks.push(serializeLedConfig(config.leds[i]));
|
|
1138
|
+
}
|
|
1139
|
+
for (let i = 0; i < Math.min(config.sensors.length, 4); i++) {
|
|
1140
|
+
chunks.push(new Uint8Array([i, PACKET_TYPE_SENSOR2]));
|
|
1141
|
+
chunks.push(serializeSensorData(config.sensors[i]));
|
|
1142
|
+
}
|
|
1143
|
+
for (let i = 0; i < Math.min(config.dataBuses.length, 4); i++) {
|
|
1144
|
+
chunks.push(new Uint8Array([i, PACKET_TYPE_DATABUS2]));
|
|
1145
|
+
chunks.push(serializeDataBus(config.dataBuses[i]));
|
|
1146
|
+
}
|
|
1147
|
+
for (let i = 0; i < Math.min(config.binaryInputs.length, 4); i++) {
|
|
1148
|
+
chunks.push(new Uint8Array([i, PACKET_TYPE_BINARY_INPUT2]));
|
|
1149
|
+
chunks.push(serializeBinaryInputs(config.binaryInputs[i]));
|
|
1150
|
+
}
|
|
1151
|
+
const totalDataSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1152
|
+
if (totalDataSize + 2 > 4096) {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
`Config size ${totalDataSize + 2} bytes exceeds maximum 4096 bytes`
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
const packetData = new Uint8Array(totalDataSize);
|
|
1158
|
+
let offset = 0;
|
|
1159
|
+
for (const chunk of chunks) {
|
|
1160
|
+
packetData.set(chunk, offset);
|
|
1161
|
+
offset += chunk.length;
|
|
1162
|
+
}
|
|
1163
|
+
const crc16 = calculateConfigCrc(packetData);
|
|
1164
|
+
const result = new Uint8Array(totalDataSize + 2);
|
|
1165
|
+
result.set(packetData, 0);
|
|
1166
|
+
const crcView = new DataView(result.buffer, totalDataSize, 2);
|
|
1167
|
+
crcView.setUint16(0, crc16, true);
|
|
1168
|
+
return result;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/transport/notification-queue.ts
|
|
1172
|
+
var NotificationQueue = class {
|
|
1173
|
+
queue = [];
|
|
1174
|
+
pendingResolvers = [];
|
|
1175
|
+
/**
|
|
1176
|
+
* Add a notification to the queue.
|
|
1177
|
+
*
|
|
1178
|
+
* If there are pending consumers waiting, immediately resolve the oldest one.
|
|
1179
|
+
* Otherwise, buffer the notification for future consumption.
|
|
1180
|
+
*
|
|
1181
|
+
* @param data - Notification data received from BLE characteristic
|
|
1182
|
+
*/
|
|
1183
|
+
enqueue(data) {
|
|
1184
|
+
if (this.pendingResolvers.length > 0) {
|
|
1185
|
+
const pending = this.pendingResolvers.shift();
|
|
1186
|
+
clearTimeout(pending.timeoutId);
|
|
1187
|
+
pending.resolve(data);
|
|
1188
|
+
} else {
|
|
1189
|
+
this.queue.push(data);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Get the next notification from the queue.
|
|
1194
|
+
*
|
|
1195
|
+
* If a notification is already buffered, return it immediately.
|
|
1196
|
+
* Otherwise, wait for the next notification or timeout.
|
|
1197
|
+
*
|
|
1198
|
+
* @param timeoutMs - Maximum time to wait in milliseconds
|
|
1199
|
+
* @returns Promise that resolves with notification data
|
|
1200
|
+
* @throws {BLETimeoutError} If timeout expires before notification arrives
|
|
1201
|
+
*/
|
|
1202
|
+
async dequeue(timeoutMs) {
|
|
1203
|
+
if (this.queue.length > 0) {
|
|
1204
|
+
return this.queue.shift();
|
|
1205
|
+
}
|
|
1206
|
+
return new Promise((resolve, reject) => {
|
|
1207
|
+
const timeoutId = window.setTimeout(() => {
|
|
1208
|
+
const index = this.pendingResolvers.findIndex(
|
|
1209
|
+
(p) => p.resolve === resolve
|
|
1210
|
+
);
|
|
1211
|
+
if (index !== -1) {
|
|
1212
|
+
this.pendingResolvers.splice(index, 1);
|
|
1213
|
+
reject(
|
|
1214
|
+
new BLETimeoutError(
|
|
1215
|
+
`No response received within ${timeoutMs}ms timeout`
|
|
1216
|
+
)
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
}, timeoutMs);
|
|
1220
|
+
this.pendingResolvers.push({ resolve, reject, timeoutId });
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Clear the queue and reject all pending requests.
|
|
1225
|
+
*
|
|
1226
|
+
* Called when the connection is closed or reset.
|
|
1227
|
+
*
|
|
1228
|
+
* @param reason - Reason for clearing (default: "Connection closed")
|
|
1229
|
+
*/
|
|
1230
|
+
clear(reason = "Connection closed") {
|
|
1231
|
+
this.queue = [];
|
|
1232
|
+
for (const pending of this.pendingResolvers) {
|
|
1233
|
+
clearTimeout(pending.timeoutId);
|
|
1234
|
+
pending.reject(new Error(reason));
|
|
1235
|
+
}
|
|
1236
|
+
this.pendingResolvers = [];
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Get the number of buffered notifications.
|
|
1240
|
+
*/
|
|
1241
|
+
get size() {
|
|
1242
|
+
return this.queue.length;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Get the number of pending consumers waiting for notifications.
|
|
1246
|
+
*/
|
|
1247
|
+
get pendingCount() {
|
|
1248
|
+
return this.pendingResolvers.length;
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// src/transport/connection.ts
|
|
1253
|
+
var BLEConnection = class {
|
|
1254
|
+
device = null;
|
|
1255
|
+
gattServer = null;
|
|
1256
|
+
characteristic = null;
|
|
1257
|
+
notificationQueue = new NotificationQueue();
|
|
1258
|
+
disconnectHandler = null;
|
|
1259
|
+
/**
|
|
1260
|
+
* Check if currently connected to a device.
|
|
1261
|
+
*/
|
|
1262
|
+
get isConnected() {
|
|
1263
|
+
return this.gattServer?.connected ?? false;
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Get the connected device name, if available.
|
|
1267
|
+
*/
|
|
1268
|
+
get deviceName() {
|
|
1269
|
+
return this.device?.name;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Connect to an OpenDisplay device.
|
|
1273
|
+
*
|
|
1274
|
+
* @param options - Connection options (device or namePrefix filter)
|
|
1275
|
+
* @throws {BLEConnectionError} If connection fails
|
|
1276
|
+
* @throws {Error} If Web Bluetooth is not supported
|
|
1277
|
+
*/
|
|
1278
|
+
async connect(options = {}) {
|
|
1279
|
+
if (!navigator.bluetooth) {
|
|
1280
|
+
throw new Error(
|
|
1281
|
+
"Web Bluetooth API not supported in this browser. Try Chrome, Edge, or Opera on desktop/Android."
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
if (options.device) {
|
|
1286
|
+
this.device = options.device;
|
|
1287
|
+
} else {
|
|
1288
|
+
const filters = [];
|
|
1289
|
+
if (options.namePrefix) {
|
|
1290
|
+
filters.push({
|
|
1291
|
+
services: [SERVICE_UUID],
|
|
1292
|
+
namePrefix: options.namePrefix
|
|
1293
|
+
});
|
|
1294
|
+
} else {
|
|
1295
|
+
filters.push({
|
|
1296
|
+
services: [SERVICE_UUID]
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
filters.push({
|
|
1300
|
+
manufacturerData: [
|
|
1301
|
+
{
|
|
1302
|
+
companyIdentifier: MANUFACTURER_ID
|
|
1303
|
+
}
|
|
1304
|
+
]
|
|
1305
|
+
});
|
|
1306
|
+
this.device = await navigator.bluetooth.requestDevice({
|
|
1307
|
+
filters,
|
|
1308
|
+
optionalServices: [SERVICE_UUID]
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (!this.device.gatt) {
|
|
1312
|
+
throw new BLEConnectionError("Device does not support GATT");
|
|
1313
|
+
}
|
|
1314
|
+
this.gattServer = await this.device.gatt.connect();
|
|
1315
|
+
const service = await this.gattServer.getPrimaryService(SERVICE_UUID);
|
|
1316
|
+
const characteristics = await service.getCharacteristics();
|
|
1317
|
+
if (characteristics.length === 0) {
|
|
1318
|
+
throw new BLEConnectionError(
|
|
1319
|
+
"No characteristics found in OpenDisplay service"
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
this.characteristic = characteristics[0];
|
|
1323
|
+
await this.characteristic.startNotifications();
|
|
1324
|
+
this.characteristic.addEventListener(
|
|
1325
|
+
"characteristicvaluechanged",
|
|
1326
|
+
this.handleNotification.bind(this)
|
|
1327
|
+
);
|
|
1328
|
+
this.disconnectHandler = this.handleDisconnect.bind(this);
|
|
1329
|
+
this.device.addEventListener(
|
|
1330
|
+
"gattserverdisconnected",
|
|
1331
|
+
this.disconnectHandler
|
|
1332
|
+
);
|
|
1333
|
+
console.log(
|
|
1334
|
+
`Connected to ${this.device.name || "OpenDisplay device"}`
|
|
1335
|
+
);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
this.cleanup();
|
|
1338
|
+
if (error instanceof Error) {
|
|
1339
|
+
throw new BLEConnectionError(
|
|
1340
|
+
`Failed to connect: ${error.message}`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
throw new BLEConnectionError("Failed to connect to device");
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Disconnect from the device.
|
|
1348
|
+
*/
|
|
1349
|
+
async disconnect() {
|
|
1350
|
+
try {
|
|
1351
|
+
if (this.characteristic) {
|
|
1352
|
+
try {
|
|
1353
|
+
await this.characteristic.stopNotifications();
|
|
1354
|
+
} catch {
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (this.gattServer?.connected) {
|
|
1358
|
+
this.gattServer.disconnect();
|
|
1359
|
+
}
|
|
1360
|
+
} finally {
|
|
1361
|
+
this.cleanup();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Write a command to the device.
|
|
1366
|
+
*
|
|
1367
|
+
* @param data - Command data to send
|
|
1368
|
+
* @throws {BLEConnectionError} If not connected or write fails
|
|
1369
|
+
*/
|
|
1370
|
+
async writeCommand(data) {
|
|
1371
|
+
if (!this.isConnected || !this.characteristic) {
|
|
1372
|
+
throw new BLEConnectionError("Not connected to device");
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
await this.characteristic.writeValueWithResponse(data);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
if (error instanceof Error) {
|
|
1378
|
+
throw new BLEConnectionError(
|
|
1379
|
+
`Failed to write command: ${error.message}`
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
throw new BLEConnectionError("Failed to write command");
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Read the next response from the device.
|
|
1387
|
+
*
|
|
1388
|
+
* @param timeoutMs - Maximum time to wait for response
|
|
1389
|
+
* @returns Promise that resolves with response data
|
|
1390
|
+
* @throws {BLETimeoutError} If timeout expires
|
|
1391
|
+
* @throws {BLEConnectionError} If not connected
|
|
1392
|
+
*/
|
|
1393
|
+
async readResponse(timeoutMs) {
|
|
1394
|
+
if (!this.isConnected) {
|
|
1395
|
+
throw new BLEConnectionError("Not connected to device");
|
|
1396
|
+
}
|
|
1397
|
+
return this.notificationQueue.dequeue(timeoutMs);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Handle incoming BLE notifications.
|
|
1401
|
+
*
|
|
1402
|
+
* @param event - Characteristic value changed event
|
|
1403
|
+
*/
|
|
1404
|
+
handleNotification(event) {
|
|
1405
|
+
const characteristic = event.target;
|
|
1406
|
+
if (!characteristic.value) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const data = new Uint8Array(characteristic.value.buffer);
|
|
1410
|
+
this.notificationQueue.enqueue(data);
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Handle device disconnection.
|
|
1414
|
+
*/
|
|
1415
|
+
handleDisconnect() {
|
|
1416
|
+
console.log("Device disconnected");
|
|
1417
|
+
this.cleanup();
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Clean up resources and reset state.
|
|
1421
|
+
*/
|
|
1422
|
+
cleanup() {
|
|
1423
|
+
this.notificationQueue.clear();
|
|
1424
|
+
if (this.characteristic) {
|
|
1425
|
+
this.characteristic.removeEventListener(
|
|
1426
|
+
"characteristicvaluechanged",
|
|
1427
|
+
this.handleNotification.bind(this)
|
|
1428
|
+
);
|
|
1429
|
+
this.characteristic = null;
|
|
1430
|
+
}
|
|
1431
|
+
if (this.device && this.disconnectHandler) {
|
|
1432
|
+
this.device.removeEventListener(
|
|
1433
|
+
"gattserverdisconnected",
|
|
1434
|
+
this.disconnectHandler
|
|
1435
|
+
);
|
|
1436
|
+
this.disconnectHandler = null;
|
|
1437
|
+
}
|
|
1438
|
+
this.gattServer = null;
|
|
1439
|
+
this.device = null;
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
// src/device.ts
|
|
1444
|
+
var OpenDisplayDevice = class _OpenDisplayDevice {
|
|
1445
|
+
/**
|
|
1446
|
+
* Initialize OpenDisplay device.
|
|
1447
|
+
*
|
|
1448
|
+
* @param options - Device initialization options
|
|
1449
|
+
*/
|
|
1450
|
+
constructor(options = {}) {
|
|
1451
|
+
this.options = options;
|
|
1452
|
+
this._config = options.config ?? null;
|
|
1453
|
+
this._capabilities = options.capabilities ?? null;
|
|
1454
|
+
}
|
|
1455
|
+
// BLE operation timeouts (milliseconds)
|
|
1456
|
+
static TIMEOUT_FIRST_CHUNK = 1e4;
|
|
1457
|
+
// First chunk may take longer
|
|
1458
|
+
static TIMEOUT_CHUNK = 2e3;
|
|
1459
|
+
// Subsequent chunks
|
|
1460
|
+
static TIMEOUT_ACK = 5e3;
|
|
1461
|
+
// Command acknowledgments
|
|
1462
|
+
static TIMEOUT_REFRESH = 9e4;
|
|
1463
|
+
// Display refresh (firmware spec: up to 60s)
|
|
1464
|
+
connection = null;
|
|
1465
|
+
_config = null;
|
|
1466
|
+
_capabilities = null;
|
|
1467
|
+
_fwVersion = null;
|
|
1468
|
+
/**
|
|
1469
|
+
* Get full device configuration (if interrogated).
|
|
1470
|
+
*/
|
|
1471
|
+
get config() {
|
|
1472
|
+
return this._config;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Get device capabilities (width, height, color scheme, rotation).
|
|
1476
|
+
*/
|
|
1477
|
+
get capabilities() {
|
|
1478
|
+
return this._capabilities;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Get display width in pixels.
|
|
1482
|
+
*/
|
|
1483
|
+
get width() {
|
|
1484
|
+
return this.ensureCapabilities().width;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Get display height in pixels.
|
|
1488
|
+
*/
|
|
1489
|
+
get height() {
|
|
1490
|
+
return this.ensureCapabilities().height;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Get display color scheme.
|
|
1494
|
+
*/
|
|
1495
|
+
get colorScheme() {
|
|
1496
|
+
return this.ensureCapabilities().colorScheme;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Get display rotation in degrees.
|
|
1500
|
+
*/
|
|
1501
|
+
get rotation() {
|
|
1502
|
+
return this.ensureCapabilities().rotation ?? 0;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Check if currently connected to a device.
|
|
1506
|
+
*/
|
|
1507
|
+
get isConnected() {
|
|
1508
|
+
return this.connection?.isConnected ?? false;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Connect to an OpenDisplay device and optionally interrogate.
|
|
1512
|
+
*
|
|
1513
|
+
* @param connectionOptions - Optional connection parameters
|
|
1514
|
+
* @throws {BLEConnectionError} If connection fails
|
|
1515
|
+
*/
|
|
1516
|
+
async connect(connectionOptions) {
|
|
1517
|
+
this.connection = new BLEConnection();
|
|
1518
|
+
const mergedOptions = {
|
|
1519
|
+
...connectionOptions,
|
|
1520
|
+
device: this.options.device ?? connectionOptions?.device,
|
|
1521
|
+
namePrefix: this.options.namePrefix ?? connectionOptions?.namePrefix
|
|
1522
|
+
};
|
|
1523
|
+
await this.connection.connect(mergedOptions);
|
|
1524
|
+
if (!this._config && !this._capabilities) {
|
|
1525
|
+
console.log("No config provided, auto-interrogating device");
|
|
1526
|
+
await this.interrogate();
|
|
1527
|
+
}
|
|
1528
|
+
if (this._config && !this._capabilities) {
|
|
1529
|
+
this._capabilities = this.extractCapabilitiesFromConfig();
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Disconnect from the device.
|
|
1534
|
+
*/
|
|
1535
|
+
async disconnect() {
|
|
1536
|
+
if (this.connection) {
|
|
1537
|
+
await this.connection.disconnect();
|
|
1538
|
+
this.connection = null;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Read device configuration from device.
|
|
1543
|
+
*
|
|
1544
|
+
* @returns GlobalConfig with complete device configuration
|
|
1545
|
+
* @throws {ProtocolError} If interrogation fails
|
|
1546
|
+
*/
|
|
1547
|
+
async interrogate() {
|
|
1548
|
+
this.ensureConnected();
|
|
1549
|
+
console.log("Interrogating device");
|
|
1550
|
+
const cmd = buildReadConfigCommand();
|
|
1551
|
+
await this.connection.writeCommand(cmd);
|
|
1552
|
+
const response = await this.connection.readResponse(
|
|
1553
|
+
_OpenDisplayDevice.TIMEOUT_FIRST_CHUNK
|
|
1554
|
+
);
|
|
1555
|
+
const chunkData = stripCommandEcho(response, 64 /* READ_CONFIG */);
|
|
1556
|
+
const view = new DataView(chunkData.buffer, chunkData.byteOffset);
|
|
1557
|
+
const totalLength = view.getUint16(2, true);
|
|
1558
|
+
const tlvData = [chunkData.subarray(4)];
|
|
1559
|
+
let currentLength = chunkData.length - 4;
|
|
1560
|
+
console.debug(`First chunk: ${chunkData.length} bytes, total length: ${totalLength}`);
|
|
1561
|
+
while (currentLength < totalLength) {
|
|
1562
|
+
const nextResponse = await this.connection.readResponse(
|
|
1563
|
+
_OpenDisplayDevice.TIMEOUT_CHUNK
|
|
1564
|
+
);
|
|
1565
|
+
const nextChunkData = stripCommandEcho(nextResponse, 64 /* READ_CONFIG */);
|
|
1566
|
+
tlvData.push(nextChunkData.subarray(2));
|
|
1567
|
+
currentLength += nextChunkData.length - 2;
|
|
1568
|
+
console.debug(`Received chunk, total: ${currentLength}/${totalLength} bytes`);
|
|
1569
|
+
}
|
|
1570
|
+
console.log(`Received complete TLV data: ${currentLength} bytes`);
|
|
1571
|
+
const completeData = new Uint8Array(currentLength);
|
|
1572
|
+
let offset = 0;
|
|
1573
|
+
for (const chunk of tlvData) {
|
|
1574
|
+
completeData.set(chunk, offset);
|
|
1575
|
+
offset += chunk.length;
|
|
1576
|
+
}
|
|
1577
|
+
this._config = parseConfigResponse(completeData);
|
|
1578
|
+
this._capabilities = this.extractCapabilitiesFromConfig();
|
|
1579
|
+
console.log(
|
|
1580
|
+
`Interrogated device: ${this.width}x${this.height}, ${import_epaper_dithering4.ColorScheme[this.colorScheme]}, rotation=${this.rotation}\xB0`
|
|
1581
|
+
);
|
|
1582
|
+
return this._config;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Read firmware version from device.
|
|
1586
|
+
*
|
|
1587
|
+
* @returns FirmwareVersion with major, minor, and sha fields
|
|
1588
|
+
*/
|
|
1589
|
+
async readFirmwareVersion() {
|
|
1590
|
+
this.ensureConnected();
|
|
1591
|
+
console.log("Reading firmware version");
|
|
1592
|
+
const cmd = buildReadFwVersionCommand();
|
|
1593
|
+
await this.connection.writeCommand(cmd);
|
|
1594
|
+
const response = await this.connection.readResponse(
|
|
1595
|
+
_OpenDisplayDevice.TIMEOUT_ACK
|
|
1596
|
+
);
|
|
1597
|
+
this._fwVersion = parseFirmwareVersion(response);
|
|
1598
|
+
console.log(
|
|
1599
|
+
`Firmware version: ${this._fwVersion.major}.${this._fwVersion.minor} (SHA: ${this._fwVersion.sha.substring(0, 8)}...)`
|
|
1600
|
+
);
|
|
1601
|
+
return this._fwVersion;
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Reboot the device.
|
|
1605
|
+
*
|
|
1606
|
+
* Sends a reboot command to the device, which will cause an immediate
|
|
1607
|
+
* system reset. The device will NOT send an ACK response - it simply
|
|
1608
|
+
* resets after a 100ms delay.
|
|
1609
|
+
*
|
|
1610
|
+
* Warning: The BLE connection will be forcibly terminated when the device
|
|
1611
|
+
* resets. This is expected behavior. The device will restart and begin
|
|
1612
|
+
* advertising again after the reset completes (typically within a few seconds).
|
|
1613
|
+
*
|
|
1614
|
+
* @throws {BLEConnectionError} If command cannot be sent
|
|
1615
|
+
*/
|
|
1616
|
+
async reboot() {
|
|
1617
|
+
this.ensureConnected();
|
|
1618
|
+
console.log("Sending reboot command to device");
|
|
1619
|
+
const cmd = buildRebootCommand();
|
|
1620
|
+
await this.connection.writeCommand(cmd);
|
|
1621
|
+
console.log("Reboot command sent - device will reset (connection will drop)");
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Write configuration to device.
|
|
1625
|
+
*
|
|
1626
|
+
* Serializes the GlobalConfig to TLV binary format and writes it
|
|
1627
|
+
* to the device using the WRITE_CONFIG (0x0041) command with
|
|
1628
|
+
* automatic chunking for large configs.
|
|
1629
|
+
*
|
|
1630
|
+
* @param config - GlobalConfig to write to device
|
|
1631
|
+
* @throws {Error} If config serialization fails or exceeds size limit
|
|
1632
|
+
* @throws {BLEConnectionError} If write fails
|
|
1633
|
+
* @throws {ProtocolError} If device returns error response
|
|
1634
|
+
*
|
|
1635
|
+
* @example
|
|
1636
|
+
* ```typescript
|
|
1637
|
+
* // Read current config
|
|
1638
|
+
* const config = device.config;
|
|
1639
|
+
*
|
|
1640
|
+
* // Modify config
|
|
1641
|
+
* config.displays[0].rotation = 180;
|
|
1642
|
+
*
|
|
1643
|
+
* // Write back to device
|
|
1644
|
+
* await device.writeConfig(config);
|
|
1645
|
+
*
|
|
1646
|
+
* // Reboot to apply changes
|
|
1647
|
+
* await device.reboot();
|
|
1648
|
+
* ```
|
|
1649
|
+
*/
|
|
1650
|
+
async writeConfig(config) {
|
|
1651
|
+
this.ensureConnected();
|
|
1652
|
+
console.log("Writing config to device");
|
|
1653
|
+
if (!config.system) {
|
|
1654
|
+
console.warn("Config missing system packet - device may not boot correctly");
|
|
1655
|
+
}
|
|
1656
|
+
if (!config.displays || config.displays.length === 0) {
|
|
1657
|
+
throw new Error("Config must have at least one display");
|
|
1658
|
+
}
|
|
1659
|
+
const missingPackets = [];
|
|
1660
|
+
if (!config.manufacturer) {
|
|
1661
|
+
missingPackets.push("manufacturer");
|
|
1662
|
+
}
|
|
1663
|
+
if (!config.power) {
|
|
1664
|
+
missingPackets.push("power");
|
|
1665
|
+
}
|
|
1666
|
+
if (missingPackets.length > 0) {
|
|
1667
|
+
console.warn(
|
|
1668
|
+
`Config missing optional packets: ${missingPackets.join(", ")}. Device may lose these settings.`
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
const configData = serializeConfig(config);
|
|
1672
|
+
console.log(
|
|
1673
|
+
`Serialized config: ${configData.length} bytes (chunking ${configData.length > 200 ? "required" : "not needed"})`
|
|
1674
|
+
);
|
|
1675
|
+
const [firstCmd, chunkCmds] = buildWriteConfigCommand(configData);
|
|
1676
|
+
console.debug(`Sending first config chunk (${firstCmd.length} bytes)`);
|
|
1677
|
+
await this.connection.writeCommand(firstCmd);
|
|
1678
|
+
let response = await this.connection.readResponse(
|
|
1679
|
+
_OpenDisplayDevice.TIMEOUT_ACK
|
|
1680
|
+
);
|
|
1681
|
+
validateAckResponse(response, 65 /* WRITE_CONFIG */);
|
|
1682
|
+
for (let i = 0; i < chunkCmds.length; i++) {
|
|
1683
|
+
const chunkCmd = chunkCmds[i];
|
|
1684
|
+
console.debug(
|
|
1685
|
+
`Sending config chunk ${i + 1}/${chunkCmds.length} (${chunkCmd.length} bytes)`
|
|
1686
|
+
);
|
|
1687
|
+
await this.connection.writeCommand(chunkCmd);
|
|
1688
|
+
response = await this.connection.readResponse(
|
|
1689
|
+
_OpenDisplayDevice.TIMEOUT_ACK
|
|
1690
|
+
);
|
|
1691
|
+
validateAckResponse(response, 66 /* WRITE_CONFIG_CHUNK */);
|
|
1692
|
+
}
|
|
1693
|
+
console.log("Config written successfully");
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Upload image to device display.
|
|
1697
|
+
*
|
|
1698
|
+
* Automatically handles:
|
|
1699
|
+
* - Image resizing to display dimensions
|
|
1700
|
+
* - Dithering based on color scheme
|
|
1701
|
+
* - Encoding to device format
|
|
1702
|
+
* - Compression
|
|
1703
|
+
* - Direct write protocol
|
|
1704
|
+
*
|
|
1705
|
+
* @param imageData - Image as ImageData (from canvas or OffscreenCanvas)
|
|
1706
|
+
* @param options - Upload options
|
|
1707
|
+
* @throws {Error} If device not interrogated/configured
|
|
1708
|
+
* @throws {ProtocolError} If upload fails
|
|
1709
|
+
*
|
|
1710
|
+
* @example
|
|
1711
|
+
* ```typescript
|
|
1712
|
+
* const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
|
|
1713
|
+
* const ctx = canvas.getContext('2d')!;
|
|
1714
|
+
* const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1715
|
+
*
|
|
1716
|
+
* await device.uploadImage(imageData, {
|
|
1717
|
+
* refreshMode: RefreshMode.FULL,
|
|
1718
|
+
* ditherMode: DitherMode.BURKES,
|
|
1719
|
+
* compress: true
|
|
1720
|
+
* });
|
|
1721
|
+
* ```
|
|
1722
|
+
*/
|
|
1723
|
+
async uploadImage(imageData, options = {}) {
|
|
1724
|
+
this.ensureConnected();
|
|
1725
|
+
this.ensureCapabilities();
|
|
1726
|
+
const refreshMode = options.refreshMode ?? 0 /* FULL */;
|
|
1727
|
+
const ditherMode = options.ditherMode ?? import_epaper_dithering4.DitherMode.BURKES;
|
|
1728
|
+
const compress = options.compress ?? true;
|
|
1729
|
+
console.log(
|
|
1730
|
+
`Uploading image (${this.width}x${this.height}, ${import_epaper_dithering4.ColorScheme[this.colorScheme]})`
|
|
1731
|
+
);
|
|
1732
|
+
const encodedData = prepareImageForUpload(
|
|
1733
|
+
imageData,
|
|
1734
|
+
this.width,
|
|
1735
|
+
this.height,
|
|
1736
|
+
this.colorScheme,
|
|
1737
|
+
ditherMode
|
|
1738
|
+
);
|
|
1739
|
+
let compressedData = null;
|
|
1740
|
+
if (compress) {
|
|
1741
|
+
compressedData = compressImageData(encodedData, 6);
|
|
1742
|
+
if (compressedData.length < MAX_COMPRESSED_SIZE) {
|
|
1743
|
+
console.log(`Using compressed upload protocol (size: ${compressedData.length} bytes)`);
|
|
1744
|
+
await this.executeUpload({
|
|
1745
|
+
imageData: encodedData,
|
|
1746
|
+
refreshMode,
|
|
1747
|
+
useCompression: true,
|
|
1748
|
+
compressedData,
|
|
1749
|
+
uncompressedSize: encodedData.length
|
|
1750
|
+
});
|
|
1751
|
+
} else {
|
|
1752
|
+
console.log(
|
|
1753
|
+
`Compressed size exceeds ${MAX_COMPRESSED_SIZE} bytes, using uncompressed protocol`
|
|
1754
|
+
);
|
|
1755
|
+
await this.executeUpload({ imageData: encodedData, refreshMode });
|
|
1756
|
+
}
|
|
1757
|
+
} else {
|
|
1758
|
+
console.log("Compression disabled, using uncompressed protocol");
|
|
1759
|
+
await this.executeUpload({ imageData: encodedData, refreshMode });
|
|
1760
|
+
}
|
|
1761
|
+
console.log("Image upload complete");
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Execute image upload using compressed or uncompressed protocol.
|
|
1765
|
+
*/
|
|
1766
|
+
async executeUpload(params) {
|
|
1767
|
+
const {
|
|
1768
|
+
imageData,
|
|
1769
|
+
refreshMode,
|
|
1770
|
+
useCompression = false,
|
|
1771
|
+
compressedData,
|
|
1772
|
+
uncompressedSize
|
|
1773
|
+
} = params;
|
|
1774
|
+
let startCmd;
|
|
1775
|
+
let remainingCompressed = null;
|
|
1776
|
+
if (useCompression && compressedData && uncompressedSize) {
|
|
1777
|
+
[startCmd, remainingCompressed] = buildDirectWriteStartCompressed(
|
|
1778
|
+
uncompressedSize,
|
|
1779
|
+
compressedData
|
|
1780
|
+
);
|
|
1781
|
+
} else {
|
|
1782
|
+
startCmd = buildDirectWriteStartUncompressed();
|
|
1783
|
+
}
|
|
1784
|
+
await this.connection.writeCommand(startCmd);
|
|
1785
|
+
let response = await this.connection.readResponse(
|
|
1786
|
+
_OpenDisplayDevice.TIMEOUT_ACK
|
|
1787
|
+
);
|
|
1788
|
+
validateAckResponse(response, 112 /* DIRECT_WRITE_START */);
|
|
1789
|
+
let autoCompleted = false;
|
|
1790
|
+
if (useCompression && remainingCompressed && remainingCompressed.length > 0) {
|
|
1791
|
+
autoCompleted = await this.sendDataChunks(remainingCompressed);
|
|
1792
|
+
} else if (!useCompression) {
|
|
1793
|
+
autoCompleted = await this.sendDataChunks(imageData);
|
|
1794
|
+
}
|
|
1795
|
+
if (!autoCompleted) {
|
|
1796
|
+
const endCmd = buildDirectWriteEndCommand(refreshMode);
|
|
1797
|
+
await this.connection.writeCommand(endCmd);
|
|
1798
|
+
response = await this.connection.readResponse(
|
|
1799
|
+
_OpenDisplayDevice.TIMEOUT_REFRESH
|
|
1800
|
+
);
|
|
1801
|
+
validateAckResponse(response, 114 /* DIRECT_WRITE_END */);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Send image data chunks with ACK handling.
|
|
1806
|
+
*
|
|
1807
|
+
* Sends image data in chunks via 0x0071 DATA commands. Handles:
|
|
1808
|
+
* - Timeout recovery when firmware starts display refresh
|
|
1809
|
+
* - Auto-completion detection (firmware sends 0x0072 END early)
|
|
1810
|
+
* - Progress logging
|
|
1811
|
+
*
|
|
1812
|
+
* @param imageData - Data to send in chunks
|
|
1813
|
+
* @returns True if device auto-completed (sent 0x0072 END early), false if all chunks sent normally
|
|
1814
|
+
* @throws {ProtocolError} If unexpected response received
|
|
1815
|
+
* @throws {BLETimeoutError} If no response within timeout
|
|
1816
|
+
*/
|
|
1817
|
+
async sendDataChunks(imageData) {
|
|
1818
|
+
let bytesSent = 0;
|
|
1819
|
+
let chunksSent = 0;
|
|
1820
|
+
while (bytesSent < imageData.length) {
|
|
1821
|
+
const chunkStart = bytesSent;
|
|
1822
|
+
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, imageData.length);
|
|
1823
|
+
const chunkData = imageData.subarray(chunkStart, chunkEnd);
|
|
1824
|
+
const dataCmd = buildDirectWriteDataCommand(chunkData);
|
|
1825
|
+
await this.connection.writeCommand(dataCmd);
|
|
1826
|
+
bytesSent += chunkData.length;
|
|
1827
|
+
chunksSent++;
|
|
1828
|
+
let response;
|
|
1829
|
+
try {
|
|
1830
|
+
response = await this.connection.readResponse(
|
|
1831
|
+
_OpenDisplayDevice.TIMEOUT_ACK
|
|
1832
|
+
);
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
if (error instanceof BLETimeoutError) {
|
|
1835
|
+
console.log(
|
|
1836
|
+
`No response after chunk ${chunksSent} (${(bytesSent / imageData.length * 100).toFixed(1)}%), waiting for device refresh...`
|
|
1837
|
+
);
|
|
1838
|
+
response = await this.connection.readResponse(
|
|
1839
|
+
_OpenDisplayDevice.TIMEOUT_REFRESH
|
|
1840
|
+
);
|
|
1841
|
+
} else {
|
|
1842
|
+
throw error;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
const [command, isAck] = checkResponseType(response);
|
|
1846
|
+
if (command === 113 /* DIRECT_WRITE_DATA */) {
|
|
1847
|
+
} else if (command === 114 /* DIRECT_WRITE_END */) {
|
|
1848
|
+
console.log(
|
|
1849
|
+
`Received END response after chunk ${chunksSent} - device auto-completed`
|
|
1850
|
+
);
|
|
1851
|
+
return true;
|
|
1852
|
+
} else {
|
|
1853
|
+
throw new ProtocolError(
|
|
1854
|
+
`Unexpected response: ${CommandCode[command]} (0x${command.toString(16).padStart(4, "0")})`
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
if (chunksSent % 50 === 0 || bytesSent >= imageData.length) {
|
|
1858
|
+
console.debug(
|
|
1859
|
+
`Sent ${bytesSent}/${imageData.length} bytes (${(bytesSent / imageData.length * 100).toFixed(1)}%)`
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
console.debug(`All data chunks sent (${chunksSent} chunks total)`);
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Extract DeviceCapabilities from GlobalConfig.
|
|
1868
|
+
*/
|
|
1869
|
+
extractCapabilitiesFromConfig() {
|
|
1870
|
+
if (!this._config) {
|
|
1871
|
+
throw new Error("No config available");
|
|
1872
|
+
}
|
|
1873
|
+
if (!this._config.displays || this._config.displays.length === 0) {
|
|
1874
|
+
throw new Error("Config has no display information");
|
|
1875
|
+
}
|
|
1876
|
+
const display = this._config.displays[0];
|
|
1877
|
+
return {
|
|
1878
|
+
width: display.pixelWidth,
|
|
1879
|
+
height: display.pixelHeight,
|
|
1880
|
+
colorScheme: display.colorScheme,
|
|
1881
|
+
rotation: display.rotation
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Ensure device capabilities are available.
|
|
1886
|
+
*/
|
|
1887
|
+
ensureCapabilities() {
|
|
1888
|
+
if (!this._capabilities) {
|
|
1889
|
+
throw new Error(
|
|
1890
|
+
"Device capabilities unknown - interrogate first or provide config/capabilities"
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
return this._capabilities;
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Ensure device is connected.
|
|
1897
|
+
*/
|
|
1898
|
+
ensureConnected() {
|
|
1899
|
+
if (!this.connection || !this.isConnected) {
|
|
1900
|
+
throw new BLEConnectionError("Not connected to device");
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
// src/discovery.ts
|
|
1906
|
+
async function discoverDevices(namePrefix) {
|
|
1907
|
+
if (!navigator.bluetooth) {
|
|
1908
|
+
throw new Error(
|
|
1909
|
+
"Web Bluetooth API not supported in this browser. Try Chrome, Edge, or Opera on desktop/Android."
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
const filters = [];
|
|
1913
|
+
if (namePrefix) {
|
|
1914
|
+
filters.push({
|
|
1915
|
+
services: [SERVICE_UUID],
|
|
1916
|
+
namePrefix
|
|
1917
|
+
});
|
|
1918
|
+
} else {
|
|
1919
|
+
filters.push({
|
|
1920
|
+
services: [SERVICE_UUID]
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
filters.push({
|
|
1924
|
+
manufacturerData: [
|
|
1925
|
+
{
|
|
1926
|
+
companyIdentifier: MANUFACTURER_ID
|
|
1927
|
+
}
|
|
1928
|
+
]
|
|
1929
|
+
});
|
|
1930
|
+
try {
|
|
1931
|
+
const device = await navigator.bluetooth.requestDevice({
|
|
1932
|
+
filters,
|
|
1933
|
+
optionalServices: [SERVICE_UUID]
|
|
1934
|
+
});
|
|
1935
|
+
console.log(`Selected device: ${device.name || "Unknown"}`);
|
|
1936
|
+
return device;
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
if (error instanceof Error) {
|
|
1939
|
+
throw new Error(`Device selection failed: ${error.message}`);
|
|
1940
|
+
}
|
|
1941
|
+
throw new Error("Device selection cancelled or failed");
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/models/advertisement.ts
|
|
1946
|
+
function parseAdvertisement(data) {
|
|
1947
|
+
if (data.length < 11) {
|
|
1948
|
+
throw new Error(
|
|
1949
|
+
`Advertisement data too short: ${data.length} bytes (need 11)`
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
const view = new DataView(data.buffer, data.byteOffset);
|
|
1953
|
+
const batteryMv = view.getUint16(7, true);
|
|
1954
|
+
const temperatureC = view.getInt8(9);
|
|
1955
|
+
const loopCounter = data[10];
|
|
1956
|
+
return {
|
|
1957
|
+
batteryMv,
|
|
1958
|
+
temperatureC,
|
|
1959
|
+
loopCounter
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// src/index.ts
|
|
1964
|
+
var import_epaper_dithering5 = require("@opendisplay/epaper-dithering");
|
|
1965
|
+
//# sourceMappingURL=index.cjs.map
|