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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/bluetooth-protocol.d.ts +178 -0
- package/dist/cjs/src/bluetooth-protocol.js +423 -0
- package/dist/cjs/src/bluetooth-protocol.test.d.ts +1 -0
- package/dist/cjs/src/bluetooth-protocol.test.js +389 -0
- package/dist/cjs/src/bluetooth.d.ts +2 -0
- package/dist/cjs/src/bluetooth.js +17 -1
- package/dist/cjs/src/index.d.ts +1 -1
- package/dist/cjs/src/index.js +2 -1
- package/dist/cjs/src/library.d.ts +16 -0
- package/dist/cjs/src/library.js +59 -40
- package/dist/cjs/src/library.test.js +133 -0
- package/dist/esm/package.json +1 -1
- package/dist/esm/src/bluetooth-protocol.d.ts +178 -0
- package/dist/esm/src/bluetooth-protocol.js +415 -0
- package/dist/esm/src/bluetooth-protocol.test.d.ts +1 -0
- package/dist/esm/src/bluetooth-protocol.test.js +387 -0
- package/dist/esm/src/bluetooth.d.ts +2 -0
- package/dist/esm/src/bluetooth.js +8 -0
- package/dist/esm/src/index.d.ts +1 -1
- package/dist/esm/src/index.js +1 -1
- package/dist/esm/src/library.d.ts +16 -0
- package/dist/esm/src/library.js +57 -39
- package/dist/esm/src/library.test.js +134 -1
- package/package.json +1 -1
- package/src/bluetooth-protocol.test.ts +497 -0
- package/src/bluetooth-protocol.ts +524 -0
- package/src/bluetooth.ts +21 -0
- package/src/index.ts +1 -1
- package/src/library.test.ts +173 -1
- package/src/library.ts +69 -48
|
@@ -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,
|