edilkamin 1.10.1 → 1.11.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.
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Edilkamin BLE Protocol Implementation
3
+ *
4
+ * Transport-agnostic protocol layer for communicating with Edilkamin stoves via BLE.
5
+ * Handles AES-128-CBC encryption, CRC16-Modbus checksums, and Modbus packet building/parsing.
6
+ *
7
+ * The consuming application is responsible for:
8
+ * - BLE device scanning and connection
9
+ * - Writing to BLE characteristics
10
+ * - Subscribing to BLE notifications
11
+ *
12
+ * Protocol details derived from: https://github.com/netmb/Edilkamin_BT
13
+ */
14
+
15
+ // =============================================================================
16
+ // BLE Characteristic UUIDs (for consuming apps)
17
+ // =============================================================================
18
+
19
+ /** Edilkamin GATT service UUID */
20
+ export const SERVICE_UUID = "0000abf0-0000-1000-8000-00805f9b34fb";
21
+
22
+ /** Write characteristic UUID (WRITE NO RESPONSE) */
23
+ export const WRITE_CHARACTERISTIC_UUID = "0000abf1-0000-1000-8000-00805f9b34fb";
24
+
25
+ /** Notify characteristic UUID (NOTIFY) */
26
+ export const NOTIFY_CHARACTERISTIC_UUID =
27
+ "0000abf2-0000-1000-8000-00805f9b34fb";
28
+
29
+ // =============================================================================
30
+ // Types
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Parsed Modbus response from the device.
35
+ */
36
+ export interface ModbusResponse {
37
+ /** Slave address (always 0x01 for Edilkamin) */
38
+ slaveAddress: number;
39
+ /** Function code (0x03 for read, 0x06 for write) */
40
+ functionCode: number;
41
+ /** Byte count (for read responses) */
42
+ byteCount?: number;
43
+ /** Response data */
44
+ data: Uint8Array;
45
+ /** Whether the response indicates an error */
46
+ isError: boolean;
47
+ }
48
+
49
+ // =============================================================================
50
+ // Constants (private)
51
+ // =============================================================================
52
+
53
+ /** AES-128-CBC key (16 bytes) */
54
+ const AES_KEY = new Uint8Array([
55
+ 0x80, 0x29, 0x47, 0x46, 0xdb, 0x35, 0x4d, 0xb7, 0x4c, 0x37, 0x01, 0xcf, 0x30,
56
+ 0xef, 0xdd, 0x65,
57
+ ]);
58
+
59
+ /** AES-128-CBC initialization vector (16 bytes) */
60
+ const AES_IV = new Uint8Array([
61
+ 0xda, 0x1a, 0x55, 0x73, 0x49, 0xf2, 0x5c, 0x64, 0x1b, 0x1a, 0x21, 0xd2, 0x6f,
62
+ 0x5b, 0x21, 0x8a,
63
+ ]);
64
+
65
+ /** Fixed key embedded in every packet (16 bytes) */
66
+ const FIXED_KEY = new Uint8Array([
67
+ 0x31, 0xdd, 0x34, 0x51, 0x26, 0x39, 0x20, 0x23, 0x9f, 0x4b, 0x68, 0x20, 0xe7,
68
+ 0x25, 0xfc, 0x75,
69
+ ]);
70
+
71
+ /** CRC16-Modbus high byte lookup table */
72
+ const CRC_HI_TABLE = new Uint8Array([
73
+ 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00,
74
+ 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1,
75
+ 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81,
76
+ 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40,
77
+ 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01,
78
+ 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0,
79
+ 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80,
80
+ 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41,
81
+ 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00,
82
+ 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0,
83
+ 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80,
84
+ 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41,
85
+ 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01,
86
+ 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1,
87
+ 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81,
88
+ 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40,
89
+ 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01,
90
+ 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1,
91
+ 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80,
92
+ 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40,
93
+ ]);
94
+
95
+ /** CRC16-Modbus low byte lookup table */
96
+ const CRC_LO_TABLE = new Uint8Array([
97
+ 0x00, 0xc0, 0xc1, 0x01, 0xc3, 0x03, 0x02, 0xc2, 0xc6, 0x06, 0x07, 0xc7, 0x05,
98
+ 0xc5, 0xc4, 0x04, 0xcc, 0x0c, 0x0d, 0xcd, 0x0f, 0xcf, 0xce, 0x0e, 0x0a, 0xca,
99
+ 0xcb, 0x0b, 0xc9, 0x09, 0x08, 0xc8, 0xd8, 0x18, 0x19, 0xd9, 0x1b, 0xdb, 0xda,
100
+ 0x1a, 0x1e, 0xde, 0xdf, 0x1f, 0xdd, 0x1d, 0x1c, 0xdc, 0x14, 0xd4, 0xd5, 0x15,
101
+ 0xd7, 0x17, 0x16, 0xd6, 0xd2, 0x12, 0x13, 0xd3, 0x11, 0xd1, 0xd0, 0x10, 0xf0,
102
+ 0x30, 0x31, 0xf1, 0x33, 0xf3, 0xf2, 0x32, 0x36, 0xf6, 0xf7, 0x37, 0xf5, 0x35,
103
+ 0x34, 0xf4, 0x3c, 0xfc, 0xfd, 0x3d, 0xff, 0x3f, 0x3e, 0xfe, 0xfa, 0x3a, 0x3b,
104
+ 0xfb, 0x39, 0xf9, 0xf8, 0x38, 0x28, 0xe8, 0xe9, 0x29, 0xeb, 0x2b, 0x2a, 0xea,
105
+ 0xee, 0x2e, 0x2f, 0xef, 0x2d, 0xed, 0xec, 0x2c, 0xe4, 0x24, 0x25, 0xe5, 0x27,
106
+ 0xe7, 0xe6, 0x26, 0x22, 0xe2, 0xe3, 0x23, 0xe1, 0x21, 0x20, 0xe0, 0xa0, 0x60,
107
+ 0x61, 0xa1, 0x63, 0xa3, 0xa2, 0x62, 0x66, 0xa6, 0xa7, 0x67, 0xa5, 0x65, 0x64,
108
+ 0xa4, 0x6c, 0xac, 0xad, 0x6d, 0xaf, 0x6f, 0x6e, 0xae, 0xaa, 0x6a, 0x6b, 0xab,
109
+ 0x69, 0xa9, 0xa8, 0x68, 0x78, 0xb8, 0xb9, 0x79, 0xbb, 0x7b, 0x7a, 0xba, 0xbe,
110
+ 0x7e, 0x7f, 0xbf, 0x7d, 0xbd, 0xbc, 0x7c, 0xb4, 0x74, 0x75, 0xb5, 0x77, 0xb7,
111
+ 0xb6, 0x76, 0x72, 0xb2, 0xb3, 0x73, 0xb1, 0x71, 0x70, 0xb0, 0x50, 0x90, 0x91,
112
+ 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54,
113
+ 0x9c, 0x5c, 0x5d, 0x9d, 0x5f, 0x9f, 0x9e, 0x5e, 0x5a, 0x9a, 0x9b, 0x5b, 0x99,
114
+ 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, 0x4b, 0x8b, 0x8a, 0x4a, 0x4e, 0x8e,
115
+ 0x8f, 0x4f, 0x8d, 0x4d, 0x4c, 0x8c, 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46,
116
+ 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, 0x40,
117
+ ]);
118
+
119
+ // =============================================================================
120
+ // CRC16-Modbus
121
+ // =============================================================================
122
+
123
+ /**
124
+ * Calculate CRC16-Modbus checksum.
125
+ *
126
+ * @param data - Data to calculate CRC for
127
+ * @returns 2-byte CRC in little-endian order [crcLo, crcHi]
128
+ */
129
+ export const crc16Modbus = (data: Uint8Array): Uint8Array => {
130
+ let crcHi = 0xff;
131
+ let crcLo = 0xff;
132
+
133
+ for (let i = 0; i < data.length; i++) {
134
+ const index = crcLo ^ data[i];
135
+ crcLo = crcHi ^ CRC_HI_TABLE[index];
136
+ crcHi = CRC_LO_TABLE[index];
137
+ }
138
+
139
+ // Return [crcLo, crcHi] - note the order!
140
+ return new Uint8Array([crcLo, crcHi]);
141
+ };
142
+
143
+ // =============================================================================
144
+ // AES-128-CBC Encryption
145
+ // =============================================================================
146
+
147
+ /**
148
+ * Import AES key for encryption/decryption.
149
+ */
150
+ const importAesKey = async (
151
+ usage: "encrypt" | "decrypt",
152
+ ): Promise<CryptoKey> => {
153
+ return crypto.subtle.importKey(
154
+ "raw",
155
+ AES_KEY.buffer as ArrayBuffer,
156
+ { name: "AES-CBC" },
157
+ false,
158
+ [usage],
159
+ );
160
+ };
161
+
162
+ /**
163
+ * Encrypt data using AES-128-CBC (raw, without PKCS7 padding).
164
+ *
165
+ * This manually applies PKCS7 padding to make input a multiple of 16 bytes,
166
+ * encrypts with Web Crypto, then strips the extra padding block from output.
167
+ *
168
+ * @param plaintext - Data to encrypt (must be 32 bytes)
169
+ * @returns Encrypted data (32 bytes)
170
+ */
171
+ export const aesEncrypt = async (
172
+ plaintext: Uint8Array,
173
+ ): Promise<Uint8Array> => {
174
+ const key = await importAesKey("encrypt");
175
+
176
+ // Clone IV since AES-CBC modifies it during operation
177
+ const iv = new Uint8Array(AES_IV);
178
+
179
+ // Add PKCS7 padding (16 bytes of 0x10 for 32-byte input)
180
+ const padded = new Uint8Array(48);
181
+ padded.set(plaintext, 0);
182
+ padded.fill(0x10, 32);
183
+
184
+ const encrypted = await crypto.subtle.encrypt(
185
+ { name: "AES-CBC", iv: iv.buffer as ArrayBuffer },
186
+ key,
187
+ padded.buffer as ArrayBuffer,
188
+ );
189
+
190
+ // Return first 32 bytes (skip the padding block)
191
+ return new Uint8Array(encrypted).slice(0, 32);
192
+ };
193
+
194
+ /**
195
+ * Decrypt data using AES-128-CBC (raw, handling PKCS7 padding).
196
+ *
197
+ * @param ciphertext - Data to decrypt (must be 32 bytes)
198
+ * @returns Decrypted data (32 bytes)
199
+ */
200
+ export const aesDecrypt = async (
201
+ ciphertext: Uint8Array,
202
+ ): Promise<Uint8Array> => {
203
+ const key = await importAesKey("decrypt");
204
+
205
+ // Clone IV since AES-CBC modifies it during operation
206
+ const iv = new Uint8Array(AES_IV);
207
+
208
+ // To decrypt without padding validation, we need to:
209
+ // 1. Decrypt to get raw blocks
210
+ // 2. Handle padding ourselves
211
+ //
212
+ // The trick is to append a valid padding block that we encrypt separately,
213
+ // then decrypt the whole thing.
214
+
215
+ // First, encrypt a padding block using the last ciphertext block as IV
216
+ // This creates the correct padding block for decryption
217
+ const lastBlock = ciphertext.slice(16, 32);
218
+ const paddingPlain = new Uint8Array(16).fill(0x10); // Valid PKCS7 for 0 extra bytes
219
+
220
+ const paddingKey = await importAesKey("encrypt");
221
+ const encryptedPadding = await crypto.subtle.encrypt(
222
+ { name: "AES-CBC", iv: lastBlock.buffer as ArrayBuffer },
223
+ paddingKey,
224
+ paddingPlain.buffer as ArrayBuffer,
225
+ );
226
+
227
+ // Build full ciphertext with valid padding block
228
+ const fullCiphertext = new Uint8Array(48);
229
+ fullCiphertext.set(ciphertext, 0);
230
+ fullCiphertext.set(new Uint8Array(encryptedPadding).slice(0, 16), 32);
231
+
232
+ const decrypted = await crypto.subtle.decrypt(
233
+ { name: "AES-CBC", iv: iv.buffer as ArrayBuffer },
234
+ key,
235
+ fullCiphertext.buffer as ArrayBuffer,
236
+ );
237
+
238
+ // Return first 32 bytes (the actual data)
239
+ return new Uint8Array(decrypted).slice(0, 32);
240
+ };
241
+
242
+ // =============================================================================
243
+ // Packet Building
244
+ // =============================================================================
245
+
246
+ /**
247
+ * Get current Unix timestamp as 4 big-endian bytes.
248
+ */
249
+ const getTimestamp = (): Uint8Array => {
250
+ const now = Math.floor(Date.now() / 1000);
251
+ return new Uint8Array([
252
+ (now >> 24) & 0xff,
253
+ (now >> 16) & 0xff,
254
+ (now >> 8) & 0xff,
255
+ now & 0xff,
256
+ ]);
257
+ };
258
+
259
+ /**
260
+ * Build and encrypt a command packet to send to the device.
261
+ *
262
+ * Packet structure (32 bytes before encryption):
263
+ * - Bytes 0-3: Unix timestamp (big-endian)
264
+ * - Bytes 4-19: Fixed key
265
+ * - Bytes 20-25: Modbus command (6 bytes)
266
+ * - Bytes 26-27: CRC16-Modbus of command
267
+ * - Bytes 28-31: Padding [0x04, 0x04, 0x04, 0x04]
268
+ *
269
+ * @param modbusCommand - 6-byte Modbus RTU command
270
+ * @returns 32-byte encrypted packet ready to send via BLE
271
+ */
272
+ export const createPacket = async (
273
+ modbusCommand: Uint8Array,
274
+ ): Promise<Uint8Array> => {
275
+ if (modbusCommand.length !== 6) {
276
+ throw new Error("Modbus command must be exactly 6 bytes");
277
+ }
278
+
279
+ // Build 32-byte plaintext packet
280
+ const packet = new Uint8Array(32);
281
+
282
+ // Timestamp (4 bytes, big-endian)
283
+ packet.set(getTimestamp(), 0);
284
+
285
+ // Fixed key (16 bytes)
286
+ packet.set(FIXED_KEY, 4);
287
+
288
+ // Modbus payload (6 bytes)
289
+ packet.set(modbusCommand, 20);
290
+
291
+ // CRC16-Modbus (2 bytes)
292
+ const crc = crc16Modbus(modbusCommand);
293
+ packet.set(crc, 26);
294
+
295
+ // Padding (4 bytes)
296
+ packet.set([0x04, 0x04, 0x04, 0x04], 28);
297
+
298
+ // Encrypt with AES-128-CBC
299
+ return aesEncrypt(packet);
300
+ };
301
+
302
+ // =============================================================================
303
+ // Response Parsing
304
+ // =============================================================================
305
+
306
+ /**
307
+ * Decrypt and parse a response packet from the device.
308
+ *
309
+ * Response structure (32 bytes before decryption):
310
+ * - Bytes 0-3: Unix timestamp
311
+ * - Bytes 4-19: Fixed key
312
+ * - Bytes 20-26: Modbus response (7 bytes)
313
+ * - Bytes 27-28: CRC16-Modbus
314
+ * - Bytes 29-31: Padding [0x03, 0x03, 0x03]
315
+ *
316
+ * @param encrypted - 32-byte encrypted response from BLE notification
317
+ * @returns Parsed Modbus response
318
+ */
319
+ export const parseResponse = async (
320
+ encrypted: Uint8Array,
321
+ ): Promise<ModbusResponse> => {
322
+ if (encrypted.length !== 32) {
323
+ throw new Error("Response must be exactly 32 bytes");
324
+ }
325
+
326
+ // Decrypt
327
+ const decrypted = await aesDecrypt(encrypted);
328
+
329
+ // Extract Modbus response (bytes 20-26, 7 bytes)
330
+ const modbusResponse = decrypted.slice(20, 27);
331
+
332
+ const slaveAddress = modbusResponse[0];
333
+ const functionCode = modbusResponse[1];
334
+
335
+ // Check for Modbus error (function code has high bit set)
336
+ const isError = (functionCode & 0x80) !== 0;
337
+
338
+ if (isError) {
339
+ return {
340
+ slaveAddress,
341
+ functionCode: functionCode & 0x7f,
342
+ data: new Uint8Array([modbusResponse[2]]), // Error code
343
+ isError: true,
344
+ };
345
+ }
346
+
347
+ // Read response: [slaveAddr, funcCode, byteCount, dataHi, dataLo, crcLo, crcHi]
348
+ if (functionCode === 0x03) {
349
+ const byteCount = modbusResponse[2];
350
+ return {
351
+ slaveAddress,
352
+ functionCode,
353
+ byteCount,
354
+ data: modbusResponse.slice(3, 3 + byteCount),
355
+ isError: false,
356
+ };
357
+ }
358
+
359
+ // Write response: [slaveAddr, funcCode, regHi, regLo, valHi, valLo, crcLo, crcHi]
360
+ // (echo of command - return register and value bytes)
361
+ return {
362
+ slaveAddress,
363
+ functionCode,
364
+ data: modbusResponse.slice(2, 6),
365
+ isError: false,
366
+ };
367
+ };
368
+
369
+ // =============================================================================
370
+ // Modbus Read Commands (Function Code 0x03)
371
+ // =============================================================================
372
+
373
+ /**
374
+ * Pre-built Modbus read commands for querying device state.
375
+ * Each command is 6 bytes: [SlaveAddr, FuncCode, RegHi, RegLo, CountHi, CountLo]
376
+ */
377
+ export const readCommands = {
378
+ /** Power state (0=off, 1=on) */
379
+ power: new Uint8Array([0x01, 0x03, 0x05, 0x29, 0x00, 0x01]),
380
+
381
+ /** Current ambient temperature (value / 10 = °C) */
382
+ temperature: new Uint8Array([0x01, 0x03, 0x05, 0x25, 0x00, 0x01]),
383
+
384
+ /** Target temperature (value / 10 = °C) */
385
+ targetTemperature: new Uint8Array([0x01, 0x03, 0x05, 0x37, 0x00, 0x01]),
386
+
387
+ /** Power level (1-5) */
388
+ powerLevel: new Uint8Array([0x01, 0x03, 0x04, 0x40, 0x00, 0x01]),
389
+
390
+ /** Fan 1 speed (0=auto, 1-5=speed) */
391
+ fan1Speed: new Uint8Array([0x01, 0x03, 0x05, 0x4b, 0x00, 0x01]),
392
+
393
+ /** Fan 2 speed (0=auto, 1-5=speed) */
394
+ fan2Speed: new Uint8Array([0x01, 0x03, 0x05, 0x4d, 0x00, 0x01]),
395
+
396
+ /** Device state code */
397
+ state: new Uint8Array([0x01, 0x03, 0x05, 0x3b, 0x00, 0x01]),
398
+
399
+ /** Alarm status code */
400
+ alarm: new Uint8Array([0x01, 0x03, 0x04, 0xc7, 0x00, 0x01]),
401
+
402
+ /** Pellet warning status */
403
+ pelletAlarm: new Uint8Array([0x01, 0x03, 0x04, 0xd5, 0x00, 0x01]),
404
+
405
+ /** Auto mode (0=manual, 1=auto) */
406
+ autoMode: new Uint8Array([0x01, 0x03, 0x04, 0x43, 0x00, 0x01]),
407
+
408
+ /** Standby mode status */
409
+ standby: new Uint8Array([0x01, 0x03, 0x04, 0x44, 0x00, 0x01]),
410
+ };
411
+
412
+ // =============================================================================
413
+ // Modbus Write Commands (Function Code 0x06)
414
+ // =============================================================================
415
+
416
+ /**
417
+ * Builder functions for Modbus write commands.
418
+ * Each function returns a 6-byte command: [SlaveAddr, FuncCode, RegHi, RegLo, ValHi, ValLo]
419
+ */
420
+ export const writeCommands = {
421
+ /**
422
+ * Turn power on or off.
423
+ * @param on - true to turn on, false to turn off
424
+ */
425
+ setPower: (on: boolean): Uint8Array =>
426
+ new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, on ? 0x01 : 0x00]),
427
+
428
+ /**
429
+ * Set target temperature.
430
+ * @param tempCelsius - Temperature in Celsius (e.g., 21.5)
431
+ */
432
+ setTemperature: (tempCelsius: number): Uint8Array => {
433
+ const value = Math.round(tempCelsius * 10);
434
+ return new Uint8Array([
435
+ 0x01,
436
+ 0x06,
437
+ 0x05,
438
+ 0x25,
439
+ (value >> 8) & 0xff,
440
+ value & 0xff,
441
+ ]);
442
+ },
443
+
444
+ /**
445
+ * Set power level.
446
+ * @param level - Power level (1-5)
447
+ */
448
+ setPowerLevel: (level: number): Uint8Array => {
449
+ if (level < 1 || level > 5) throw new Error("Power level must be 1-5");
450
+ return new Uint8Array([0x01, 0x06, 0x04, 0x40, 0x00, level]);
451
+ },
452
+
453
+ /**
454
+ * Set fan 1 speed.
455
+ * @param speed - Fan speed (0=auto, 1-5=manual speed)
456
+ */
457
+ setFan1Speed: (speed: number): Uint8Array => {
458
+ if (speed < 0 || speed > 5) throw new Error("Fan speed must be 0-5");
459
+ return new Uint8Array([0x01, 0x06, 0x05, 0x4b, 0x00, speed]);
460
+ },
461
+
462
+ /**
463
+ * Set fan 2 speed.
464
+ * @param speed - Fan speed (0=auto, 1-5=manual speed)
465
+ */
466
+ setFan2Speed: (speed: number): Uint8Array => {
467
+ if (speed < 0 || speed > 5) throw new Error("Fan speed must be 0-5");
468
+ return new Uint8Array([0x01, 0x06, 0x05, 0x4d, 0x00, speed]);
469
+ },
470
+
471
+ /**
472
+ * Enable or disable auto mode.
473
+ * @param enabled - true to enable auto mode
474
+ */
475
+ setAutoMode: (enabled: boolean): Uint8Array =>
476
+ new Uint8Array([0x01, 0x06, 0x04, 0x43, 0x00, enabled ? 0x01 : 0x00]),
477
+
478
+ /**
479
+ * Enable or disable standby mode.
480
+ * @param enabled - true to enable standby mode
481
+ */
482
+ setStandby: (enabled: boolean): Uint8Array =>
483
+ new Uint8Array([0x01, 0x06, 0x04, 0x44, 0x00, enabled ? 0x01 : 0x00]),
484
+ };
485
+
486
+ // =============================================================================
487
+ // Response Parsers
488
+ // =============================================================================
489
+
490
+ /**
491
+ * Parser functions to extract meaningful values from Modbus responses.
492
+ */
493
+ export const parsers = {
494
+ /**
495
+ * Parse boolean response (power state, auto mode, etc.).
496
+ * @param response - Parsed Modbus response
497
+ * @returns true if value is 0x01, false if 0x00
498
+ */
499
+ boolean: (response: ModbusResponse): boolean => {
500
+ if (response.isError) throw new Error(`Modbus error: ${response.data[0]}`);
501
+ return response.data[1] === 0x01;
502
+ },
503
+
504
+ /**
505
+ * Parse temperature response.
506
+ * @param response - Parsed Modbus response
507
+ * @returns Temperature in Celsius
508
+ */
509
+ temperature: (response: ModbusResponse): number => {
510
+ if (response.isError) throw new Error(`Modbus error: ${response.data[0]}`);
511
+ const value = (response.data[0] << 8) | response.data[1];
512
+ return value / 10;
513
+ },
514
+
515
+ /**
516
+ * Parse numeric value (power level, fan speed, state code, etc.).
517
+ * @param response - Parsed Modbus response
518
+ * @returns Numeric value
519
+ */
520
+ number: (response: ModbusResponse): number => {
521
+ if (response.isError) throw new Error(`Modbus error: ${response.data[0]}`);
522
+ return (response.data[0] << 8) | response.data[1];
523
+ },
524
+ };
package/src/bluetooth.ts CHANGED
@@ -113,3 +113,24 @@ export {
113
113
 
114
114
  // Re-export DiscoveredDevice for convenience
115
115
  export type { DiscoveredDevice } from "./types";
116
+
117
+ // Protocol functions
118
+ export {
119
+ aesDecrypt,
120
+ aesEncrypt,
121
+ crc16Modbus,
122
+ createPacket,
123
+ // Constants
124
+ NOTIFY_CHARACTERISTIC_UUID,
125
+ parseResponse,
126
+ // Commands
127
+ parsers,
128
+ readCommands,
129
+ SERVICE_UUID,
130
+ WRITE_CHARACTERISTIC_UUID,
131
+ // Parsers
132
+ writeCommands,
133
+ } from "./bluetooth-protocol";
134
+
135
+ // Protocol types
136
+ export type { ModbusResponse } from "./bluetooth-protocol";
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { configure } from "./library";
3
3
  export { bleToWifiMac } from "./bluetooth-utils";
4
4
  export { decompressBuffer, isBuffer, processResponse } from "./buffer-utils";
5
5
  export { API_URL, NEW_API_URL, OLD_API_URL } from "./constants";
6
- export { configure, getSession, signIn } from "./library";
6
+ export { configure, deriveUsageAnalytics, getSession, signIn } from "./library";
7
7
  export {
8
8
  serialNumberDisplay,
9
9
  serialNumberFromHex,