edilkamin 1.10.2 → 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/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/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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const assert_1 = require("assert");
|
|
13
|
+
const bluetooth_protocol_1 = require("./bluetooth-protocol");
|
|
14
|
+
describe("bluetooth-protocol", () => {
|
|
15
|
+
describe("constants", () => {
|
|
16
|
+
it("exports SERVICE_UUID", () => {
|
|
17
|
+
assert_1.strict.equal(bluetooth_protocol_1.SERVICE_UUID, "0000abf0-0000-1000-8000-00805f9b34fb");
|
|
18
|
+
});
|
|
19
|
+
it("exports WRITE_CHARACTERISTIC_UUID", () => {
|
|
20
|
+
assert_1.strict.equal(bluetooth_protocol_1.WRITE_CHARACTERISTIC_UUID, "0000abf1-0000-1000-8000-00805f9b34fb");
|
|
21
|
+
});
|
|
22
|
+
it("exports NOTIFY_CHARACTERISTIC_UUID", () => {
|
|
23
|
+
assert_1.strict.equal(bluetooth_protocol_1.NOTIFY_CHARACTERISTIC_UUID, "0000abf2-0000-1000-8000-00805f9b34fb");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe("crc16Modbus", () => {
|
|
27
|
+
it("calculates correct CRC for power-on command", () => {
|
|
28
|
+
const command = new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]);
|
|
29
|
+
const crc = (0, bluetooth_protocol_1.crc16Modbus)(command);
|
|
30
|
+
assert_1.strict.equal(crc.length, 2);
|
|
31
|
+
// CRC is returned as [crcLo, crcHi]
|
|
32
|
+
assert_1.strict.ok(crc[0] >= 0 && crc[0] <= 255);
|
|
33
|
+
assert_1.strict.ok(crc[1] >= 0 && crc[1] <= 255);
|
|
34
|
+
});
|
|
35
|
+
it("returns 2 bytes for any input", () => {
|
|
36
|
+
const testCases = [
|
|
37
|
+
new Uint8Array([0x01, 0x03, 0x05, 0x25, 0x00, 0x01]),
|
|
38
|
+
new Uint8Array([0x01, 0x06, 0x04, 0x40, 0x00, 0x03]),
|
|
39
|
+
new Uint8Array([0x00]),
|
|
40
|
+
new Uint8Array([0xff, 0xff, 0xff]),
|
|
41
|
+
];
|
|
42
|
+
for (const data of testCases) {
|
|
43
|
+
const crc = (0, bluetooth_protocol_1.crc16Modbus)(data);
|
|
44
|
+
assert_1.strict.equal(crc.length, 2, `CRC for ${data.toString()} should be 2 bytes`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it("produces different CRCs for different data", () => {
|
|
48
|
+
const crc1 = (0, bluetooth_protocol_1.crc16Modbus)(new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]));
|
|
49
|
+
const crc2 = (0, bluetooth_protocol_1.crc16Modbus)(new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x00]));
|
|
50
|
+
// CRCs should be different for different data
|
|
51
|
+
assert_1.strict.ok(crc1[0] !== crc2[0] || crc1[1] !== crc2[1]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("aesEncrypt/aesDecrypt", () => {
|
|
55
|
+
it("roundtrip returns original data", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
56
|
+
const original = new Uint8Array(32).fill(0x42);
|
|
57
|
+
const encrypted = yield (0, bluetooth_protocol_1.aesEncrypt)(original);
|
|
58
|
+
const decrypted = yield (0, bluetooth_protocol_1.aesDecrypt)(encrypted);
|
|
59
|
+
assert_1.strict.deepEqual(decrypted, original);
|
|
60
|
+
}));
|
|
61
|
+
it("produces 32-byte output for 32-byte input", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
62
|
+
const input = new Uint8Array(32);
|
|
63
|
+
const encrypted = yield (0, bluetooth_protocol_1.aesEncrypt)(input);
|
|
64
|
+
assert_1.strict.equal(encrypted.length, 32);
|
|
65
|
+
}));
|
|
66
|
+
it("produces different output for different input", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
67
|
+
const input1 = new Uint8Array(32).fill(0x00);
|
|
68
|
+
const input2 = new Uint8Array(32).fill(0xff);
|
|
69
|
+
const encrypted1 = yield (0, bluetooth_protocol_1.aesEncrypt)(input1);
|
|
70
|
+
const encrypted2 = yield (0, bluetooth_protocol_1.aesEncrypt)(input2);
|
|
71
|
+
// At least some bytes should be different
|
|
72
|
+
let different = false;
|
|
73
|
+
for (let i = 0; i < 32; i++) {
|
|
74
|
+
if (encrypted1[i] !== encrypted2[i]) {
|
|
75
|
+
different = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
assert_1.strict.ok(different, "Different inputs should produce different outputs");
|
|
80
|
+
}));
|
|
81
|
+
it("encrypted data is different from plaintext", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
const original = new Uint8Array(32).fill(0xaa);
|
|
83
|
+
const encrypted = yield (0, bluetooth_protocol_1.aesEncrypt)(original);
|
|
84
|
+
// Encrypted should not equal original
|
|
85
|
+
let same = true;
|
|
86
|
+
for (let i = 0; i < 32; i++) {
|
|
87
|
+
if (encrypted[i] !== original[i]) {
|
|
88
|
+
same = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assert_1.strict.ok(!same, "Encrypted data should differ from original");
|
|
93
|
+
}));
|
|
94
|
+
});
|
|
95
|
+
describe("createPacket", () => {
|
|
96
|
+
it("produces 32-byte encrypted packet", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
97
|
+
const command = bluetooth_protocol_1.readCommands.power;
|
|
98
|
+
const packet = yield (0, bluetooth_protocol_1.createPacket)(command);
|
|
99
|
+
assert_1.strict.equal(packet.length, 32);
|
|
100
|
+
}));
|
|
101
|
+
it("rejects commands not exactly 6 bytes", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
102
|
+
yield assert_1.strict.rejects(() => (0, bluetooth_protocol_1.createPacket)(new Uint8Array([0x01, 0x02])), /must be exactly 6 bytes/);
|
|
103
|
+
}));
|
|
104
|
+
it("rejects empty commands", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
105
|
+
yield assert_1.strict.rejects(() => (0, bluetooth_protocol_1.createPacket)(new Uint8Array([])), /must be exactly 6 bytes/);
|
|
106
|
+
}));
|
|
107
|
+
it("rejects 7-byte commands", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
108
|
+
yield assert_1.strict.rejects(() => (0, bluetooth_protocol_1.createPacket)(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])), /must be exactly 6 bytes/);
|
|
109
|
+
}));
|
|
110
|
+
it("produces different packets for different commands", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
111
|
+
const packet1 = yield (0, bluetooth_protocol_1.createPacket)(bluetooth_protocol_1.writeCommands.setPower(true));
|
|
112
|
+
const packet2 = yield (0, bluetooth_protocol_1.createPacket)(bluetooth_protocol_1.writeCommands.setPower(false));
|
|
113
|
+
// Packets should be different (different command bytes)
|
|
114
|
+
let different = false;
|
|
115
|
+
for (let i = 0; i < 32; i++) {
|
|
116
|
+
if (packet1[i] !== packet2[i]) {
|
|
117
|
+
different = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
assert_1.strict.ok(different, "Different commands should produce different packets");
|
|
122
|
+
}));
|
|
123
|
+
});
|
|
124
|
+
describe("parseResponse", () => {
|
|
125
|
+
it("rejects responses not 32 bytes", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
|
+
yield assert_1.strict.rejects(() => (0, bluetooth_protocol_1.parseResponse)(new Uint8Array(16)), /must be exactly 32 bytes/);
|
|
127
|
+
}));
|
|
128
|
+
it("can decrypt and parse an encrypted packet", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
129
|
+
// Create a command packet and try to parse it
|
|
130
|
+
// This tests that encrypt/decrypt work together
|
|
131
|
+
const command = bluetooth_protocol_1.readCommands.power;
|
|
132
|
+
const encrypted = yield (0, bluetooth_protocol_1.createPacket)(command);
|
|
133
|
+
// Parse the response (it's not a valid response but should decrypt)
|
|
134
|
+
const parsed = yield (0, bluetooth_protocol_1.parseResponse)(encrypted);
|
|
135
|
+
// Should have parsed something
|
|
136
|
+
assert_1.strict.ok(typeof parsed.slaveAddress === "number");
|
|
137
|
+
assert_1.strict.ok(typeof parsed.functionCode === "number");
|
|
138
|
+
assert_1.strict.ok(typeof parsed.isError === "boolean");
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
describe("readCommands", () => {
|
|
142
|
+
it("all commands are 6 bytes", () => {
|
|
143
|
+
Object.entries(bluetooth_protocol_1.readCommands).forEach(([name, cmd]) => {
|
|
144
|
+
assert_1.strict.equal(cmd.length, 6, `${name} command must be 6 bytes`);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it("all commands use slave address 0x01", () => {
|
|
148
|
+
Object.entries(bluetooth_protocol_1.readCommands).forEach(([name, cmd]) => {
|
|
149
|
+
assert_1.strict.equal(cmd[0], 0x01, `${name} should use slave address 0x01`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
it("all commands use function code 0x03", () => {
|
|
153
|
+
Object.entries(bluetooth_protocol_1.readCommands).forEach(([name, cmd]) => {
|
|
154
|
+
assert_1.strict.equal(cmd[1], 0x03, `${name} should use function code 0x03`);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it("power command has correct register address", () => {
|
|
158
|
+
assert_1.strict.equal(bluetooth_protocol_1.readCommands.power[2], 0x05);
|
|
159
|
+
assert_1.strict.equal(bluetooth_protocol_1.readCommands.power[3], 0x29);
|
|
160
|
+
});
|
|
161
|
+
it("temperature command has correct register address", () => {
|
|
162
|
+
assert_1.strict.equal(bluetooth_protocol_1.readCommands.temperature[2], 0x05);
|
|
163
|
+
assert_1.strict.equal(bluetooth_protocol_1.readCommands.temperature[3], 0x25);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("writeCommands", () => {
|
|
167
|
+
it("setPower(true) produces correct bytes", () => {
|
|
168
|
+
const cmd = bluetooth_protocol_1.writeCommands.setPower(true);
|
|
169
|
+
assert_1.strict.deepEqual(cmd, new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]));
|
|
170
|
+
});
|
|
171
|
+
it("setPower(false) produces correct bytes", () => {
|
|
172
|
+
const cmd = bluetooth_protocol_1.writeCommands.setPower(false);
|
|
173
|
+
assert_1.strict.deepEqual(cmd, new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x00]));
|
|
174
|
+
});
|
|
175
|
+
it("setTemperature encodes correctly", () => {
|
|
176
|
+
const cmd = bluetooth_protocol_1.writeCommands.setTemperature(21.5);
|
|
177
|
+
// 21.5 * 10 = 215 = 0x00D7
|
|
178
|
+
assert_1.strict.equal(cmd[0], 0x01); // slave address
|
|
179
|
+
assert_1.strict.equal(cmd[1], 0x06); // function code
|
|
180
|
+
assert_1.strict.equal(cmd[2], 0x05); // register hi
|
|
181
|
+
assert_1.strict.equal(cmd[3], 0x25); // register lo
|
|
182
|
+
assert_1.strict.equal(cmd[4], 0x00); // value hi
|
|
183
|
+
assert_1.strict.equal(cmd[5], 0xd7); // value lo
|
|
184
|
+
});
|
|
185
|
+
it("setTemperature handles whole numbers", () => {
|
|
186
|
+
const cmd = bluetooth_protocol_1.writeCommands.setTemperature(20);
|
|
187
|
+
// 20 * 10 = 200 = 0x00C8
|
|
188
|
+
assert_1.strict.equal(cmd[4], 0x00);
|
|
189
|
+
assert_1.strict.equal(cmd[5], 0xc8);
|
|
190
|
+
});
|
|
191
|
+
it("setTemperature handles high temperatures", () => {
|
|
192
|
+
const cmd = bluetooth_protocol_1.writeCommands.setTemperature(30);
|
|
193
|
+
// 30 * 10 = 300 = 0x012C
|
|
194
|
+
assert_1.strict.equal(cmd[4], 0x01);
|
|
195
|
+
assert_1.strict.equal(cmd[5], 0x2c);
|
|
196
|
+
});
|
|
197
|
+
it("setPowerLevel validates range", () => {
|
|
198
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setPowerLevel(0), /must be 1-5/);
|
|
199
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setPowerLevel(6), /must be 1-5/);
|
|
200
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setPowerLevel(1));
|
|
201
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setPowerLevel(3));
|
|
202
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setPowerLevel(5));
|
|
203
|
+
});
|
|
204
|
+
it("setPowerLevel produces correct bytes", () => {
|
|
205
|
+
const cmd = bluetooth_protocol_1.writeCommands.setPowerLevel(3);
|
|
206
|
+
assert_1.strict.equal(cmd[0], 0x01);
|
|
207
|
+
assert_1.strict.equal(cmd[1], 0x06);
|
|
208
|
+
assert_1.strict.equal(cmd[2], 0x04);
|
|
209
|
+
assert_1.strict.equal(cmd[3], 0x40);
|
|
210
|
+
assert_1.strict.equal(cmd[4], 0x00);
|
|
211
|
+
assert_1.strict.equal(cmd[5], 0x03);
|
|
212
|
+
});
|
|
213
|
+
it("setFan1Speed validates range", () => {
|
|
214
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setFan1Speed(-1), /must be 0-5/);
|
|
215
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setFan1Speed(6), /must be 0-5/);
|
|
216
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setFan1Speed(0)); // auto
|
|
217
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setFan1Speed(5));
|
|
218
|
+
});
|
|
219
|
+
it("setFan2Speed validates range", () => {
|
|
220
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setFan2Speed(-1), /must be 0-5/);
|
|
221
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.writeCommands.setFan2Speed(6), /must be 0-5/);
|
|
222
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setFan2Speed(0));
|
|
223
|
+
assert_1.strict.doesNotThrow(() => bluetooth_protocol_1.writeCommands.setFan2Speed(5));
|
|
224
|
+
});
|
|
225
|
+
it("setAutoMode produces correct bytes", () => {
|
|
226
|
+
const cmdOn = bluetooth_protocol_1.writeCommands.setAutoMode(true);
|
|
227
|
+
assert_1.strict.equal(cmdOn[5], 0x01);
|
|
228
|
+
const cmdOff = bluetooth_protocol_1.writeCommands.setAutoMode(false);
|
|
229
|
+
assert_1.strict.equal(cmdOff[5], 0x00);
|
|
230
|
+
});
|
|
231
|
+
it("setStandby produces correct bytes", () => {
|
|
232
|
+
const cmdOn = bluetooth_protocol_1.writeCommands.setStandby(true);
|
|
233
|
+
assert_1.strict.equal(cmdOn[5], 0x01);
|
|
234
|
+
const cmdOff = bluetooth_protocol_1.writeCommands.setStandby(false);
|
|
235
|
+
assert_1.strict.equal(cmdOff[5], 0x00);
|
|
236
|
+
});
|
|
237
|
+
it("all write commands are 6 bytes", () => {
|
|
238
|
+
const commands = [
|
|
239
|
+
bluetooth_protocol_1.writeCommands.setPower(true),
|
|
240
|
+
bluetooth_protocol_1.writeCommands.setTemperature(21),
|
|
241
|
+
bluetooth_protocol_1.writeCommands.setPowerLevel(3),
|
|
242
|
+
bluetooth_protocol_1.writeCommands.setFan1Speed(2),
|
|
243
|
+
bluetooth_protocol_1.writeCommands.setFan2Speed(2),
|
|
244
|
+
bluetooth_protocol_1.writeCommands.setAutoMode(true),
|
|
245
|
+
bluetooth_protocol_1.writeCommands.setStandby(false),
|
|
246
|
+
];
|
|
247
|
+
commands.forEach((cmd, i) => {
|
|
248
|
+
assert_1.strict.equal(cmd.length, 6, `Command ${i} should be 6 bytes`);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
it("all write commands use function code 0x06", () => {
|
|
252
|
+
const commands = [
|
|
253
|
+
bluetooth_protocol_1.writeCommands.setPower(true),
|
|
254
|
+
bluetooth_protocol_1.writeCommands.setTemperature(21),
|
|
255
|
+
bluetooth_protocol_1.writeCommands.setPowerLevel(3),
|
|
256
|
+
bluetooth_protocol_1.writeCommands.setFan1Speed(2),
|
|
257
|
+
bluetooth_protocol_1.writeCommands.setFan2Speed(2),
|
|
258
|
+
bluetooth_protocol_1.writeCommands.setAutoMode(true),
|
|
259
|
+
bluetooth_protocol_1.writeCommands.setStandby(false),
|
|
260
|
+
];
|
|
261
|
+
commands.forEach((cmd, i) => {
|
|
262
|
+
assert_1.strict.equal(cmd[1], 0x06, `Command ${i} should use function code 0x06`);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
describe("parsers", () => {
|
|
267
|
+
it("boolean parser returns true for 0x01", () => {
|
|
268
|
+
const response = {
|
|
269
|
+
slaveAddress: 1,
|
|
270
|
+
functionCode: 0x03,
|
|
271
|
+
byteCount: 2,
|
|
272
|
+
data: new Uint8Array([0x00, 0x01]),
|
|
273
|
+
isError: false,
|
|
274
|
+
};
|
|
275
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.boolean(response), true);
|
|
276
|
+
});
|
|
277
|
+
it("boolean parser returns false for 0x00", () => {
|
|
278
|
+
const response = {
|
|
279
|
+
slaveAddress: 1,
|
|
280
|
+
functionCode: 0x03,
|
|
281
|
+
byteCount: 2,
|
|
282
|
+
data: new Uint8Array([0x00, 0x00]),
|
|
283
|
+
isError: false,
|
|
284
|
+
};
|
|
285
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.boolean(response), false);
|
|
286
|
+
});
|
|
287
|
+
it("temperature parser divides by 10", () => {
|
|
288
|
+
const response = {
|
|
289
|
+
slaveAddress: 1,
|
|
290
|
+
functionCode: 0x03,
|
|
291
|
+
byteCount: 2,
|
|
292
|
+
data: new Uint8Array([0x00, 0xd7]), // 215
|
|
293
|
+
isError: false,
|
|
294
|
+
};
|
|
295
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.temperature(response), 21.5);
|
|
296
|
+
});
|
|
297
|
+
it("temperature parser handles high temperatures", () => {
|
|
298
|
+
const response = {
|
|
299
|
+
slaveAddress: 1,
|
|
300
|
+
functionCode: 0x03,
|
|
301
|
+
byteCount: 2,
|
|
302
|
+
data: new Uint8Array([0x01, 0x2c]), // 300
|
|
303
|
+
isError: false,
|
|
304
|
+
};
|
|
305
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.temperature(response), 30);
|
|
306
|
+
});
|
|
307
|
+
it("number parser returns big-endian value", () => {
|
|
308
|
+
const response = {
|
|
309
|
+
slaveAddress: 1,
|
|
310
|
+
functionCode: 0x03,
|
|
311
|
+
byteCount: 2,
|
|
312
|
+
data: new Uint8Array([0x00, 0x03]), // power level 3
|
|
313
|
+
isError: false,
|
|
314
|
+
};
|
|
315
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.number(response), 3);
|
|
316
|
+
});
|
|
317
|
+
it("number parser handles larger values", () => {
|
|
318
|
+
const response = {
|
|
319
|
+
slaveAddress: 1,
|
|
320
|
+
functionCode: 0x03,
|
|
321
|
+
byteCount: 2,
|
|
322
|
+
data: new Uint8Array([0x01, 0x00]), // 256
|
|
323
|
+
isError: false,
|
|
324
|
+
};
|
|
325
|
+
assert_1.strict.equal(bluetooth_protocol_1.parsers.number(response), 256);
|
|
326
|
+
});
|
|
327
|
+
it("boolean parser throws on error response", () => {
|
|
328
|
+
const errorResponse = {
|
|
329
|
+
slaveAddress: 1,
|
|
330
|
+
functionCode: 0x03,
|
|
331
|
+
data: new Uint8Array([0x02]), // error code
|
|
332
|
+
isError: true,
|
|
333
|
+
};
|
|
334
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.parsers.boolean(errorResponse), /Modbus error: 2/);
|
|
335
|
+
});
|
|
336
|
+
it("temperature parser throws on error response", () => {
|
|
337
|
+
const errorResponse = {
|
|
338
|
+
slaveAddress: 1,
|
|
339
|
+
functionCode: 0x03,
|
|
340
|
+
data: new Uint8Array([0x03]),
|
|
341
|
+
isError: true,
|
|
342
|
+
};
|
|
343
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.parsers.temperature(errorResponse), /Modbus error: 3/);
|
|
344
|
+
});
|
|
345
|
+
it("number parser throws on error response", () => {
|
|
346
|
+
const errorResponse = {
|
|
347
|
+
slaveAddress: 1,
|
|
348
|
+
functionCode: 0x03,
|
|
349
|
+
data: new Uint8Array([0x04]),
|
|
350
|
+
isError: true,
|
|
351
|
+
};
|
|
352
|
+
assert_1.strict.throws(() => bluetooth_protocol_1.parsers.number(errorResponse), /Modbus error: 4/);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
describe("integration", () => {
|
|
356
|
+
it("full roundtrip: create packet, decrypt, check structure", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
357
|
+
// Create a power-on command
|
|
358
|
+
const command = bluetooth_protocol_1.writeCommands.setPower(true);
|
|
359
|
+
assert_1.strict.deepEqual(command, new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]));
|
|
360
|
+
// Create encrypted packet
|
|
361
|
+
const packet = yield (0, bluetooth_protocol_1.createPacket)(command);
|
|
362
|
+
assert_1.strict.equal(packet.length, 32);
|
|
363
|
+
// Decrypt it back (as if we received it)
|
|
364
|
+
// Note: createPacket encrypts with padding, parseResponse expects response format
|
|
365
|
+
// This is not a true response but tests the encryption layer
|
|
366
|
+
}));
|
|
367
|
+
it("all read commands can create valid packets", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
368
|
+
for (const [name, command] of Object.entries(bluetooth_protocol_1.readCommands)) {
|
|
369
|
+
const packet = yield (0, bluetooth_protocol_1.createPacket)(command);
|
|
370
|
+
assert_1.strict.equal(packet.length, 32, `${name} should create 32-byte packet`);
|
|
371
|
+
}
|
|
372
|
+
}));
|
|
373
|
+
it("all write commands can create valid packets", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
374
|
+
const commands = [
|
|
375
|
+
{ name: "setPower", cmd: bluetooth_protocol_1.writeCommands.setPower(true) },
|
|
376
|
+
{ name: "setTemperature", cmd: bluetooth_protocol_1.writeCommands.setTemperature(21) },
|
|
377
|
+
{ name: "setPowerLevel", cmd: bluetooth_protocol_1.writeCommands.setPowerLevel(3) },
|
|
378
|
+
{ name: "setFan1Speed", cmd: bluetooth_protocol_1.writeCommands.setFan1Speed(2) },
|
|
379
|
+
{ name: "setFan2Speed", cmd: bluetooth_protocol_1.writeCommands.setFan2Speed(2) },
|
|
380
|
+
{ name: "setAutoMode", cmd: bluetooth_protocol_1.writeCommands.setAutoMode(true) },
|
|
381
|
+
{ name: "setStandby", cmd: bluetooth_protocol_1.writeCommands.setStandby(false) },
|
|
382
|
+
];
|
|
383
|
+
for (const { name, cmd } of commands) {
|
|
384
|
+
const packet = yield (0, bluetooth_protocol_1.createPacket)(cmd);
|
|
385
|
+
assert_1.strict.equal(packet.length, 32, `${name} should create 32-byte packet`);
|
|
386
|
+
}
|
|
387
|
+
}));
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -38,3 +38,5 @@ declare const scanForDevices: () => Promise<DiscoveredDevice[]>;
|
|
|
38
38
|
declare const scanWithOptions: (options: RequestDeviceOptions) => Promise<BluetoothDevice>;
|
|
39
39
|
export { EDILKAMIN_DEVICE_NAME, EDILKAMIN_SERVICE_UUID, isWebBluetoothSupported, scanForDevices, scanWithOptions, };
|
|
40
40
|
export type { DiscoveredDevice } from "./types";
|
|
41
|
+
export { aesDecrypt, aesEncrypt, crc16Modbus, createPacket, NOTIFY_CHARACTERISTIC_UUID, parseResponse, parsers, readCommands, SERVICE_UUID, WRITE_CHARACTERISTIC_UUID, writeCommands, } from "./bluetooth-protocol";
|
|
42
|
+
export type { ModbusResponse } from "./bluetooth-protocol";
|
|
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.scanWithOptions = exports.scanForDevices = exports.isWebBluetoothSupported = exports.EDILKAMIN_SERVICE_UUID = exports.EDILKAMIN_DEVICE_NAME = void 0;
|
|
12
|
+
exports.writeCommands = exports.WRITE_CHARACTERISTIC_UUID = exports.SERVICE_UUID = exports.readCommands = exports.parsers = exports.parseResponse = exports.NOTIFY_CHARACTERISTIC_UUID = exports.createPacket = exports.crc16Modbus = exports.aesEncrypt = exports.aesDecrypt = exports.scanWithOptions = exports.scanForDevices = exports.isWebBluetoothSupported = exports.EDILKAMIN_SERVICE_UUID = exports.EDILKAMIN_DEVICE_NAME = void 0;
|
|
13
13
|
const bluetooth_utils_1 = require("./bluetooth-utils");
|
|
14
14
|
/** Device name broadcast by Edilkamin stoves */
|
|
15
15
|
const EDILKAMIN_DEVICE_NAME = "EDILKAMIN_EP";
|
|
@@ -105,3 +105,19 @@ const scanWithOptions = (options) => __awaiter(void 0, void 0, void 0, function*
|
|
|
105
105
|
return navigator.bluetooth.requestDevice(options);
|
|
106
106
|
});
|
|
107
107
|
exports.scanWithOptions = scanWithOptions;
|
|
108
|
+
// Protocol functions
|
|
109
|
+
var bluetooth_protocol_1 = require("./bluetooth-protocol");
|
|
110
|
+
Object.defineProperty(exports, "aesDecrypt", { enumerable: true, get: function () { return bluetooth_protocol_1.aesDecrypt; } });
|
|
111
|
+
Object.defineProperty(exports, "aesEncrypt", { enumerable: true, get: function () { return bluetooth_protocol_1.aesEncrypt; } });
|
|
112
|
+
Object.defineProperty(exports, "crc16Modbus", { enumerable: true, get: function () { return bluetooth_protocol_1.crc16Modbus; } });
|
|
113
|
+
Object.defineProperty(exports, "createPacket", { enumerable: true, get: function () { return bluetooth_protocol_1.createPacket; } });
|
|
114
|
+
// Constants
|
|
115
|
+
Object.defineProperty(exports, "NOTIFY_CHARACTERISTIC_UUID", { enumerable: true, get: function () { return bluetooth_protocol_1.NOTIFY_CHARACTERISTIC_UUID; } });
|
|
116
|
+
Object.defineProperty(exports, "parseResponse", { enumerable: true, get: function () { return bluetooth_protocol_1.parseResponse; } });
|
|
117
|
+
// Commands
|
|
118
|
+
Object.defineProperty(exports, "parsers", { enumerable: true, get: function () { return bluetooth_protocol_1.parsers; } });
|
|
119
|
+
Object.defineProperty(exports, "readCommands", { enumerable: true, get: function () { return bluetooth_protocol_1.readCommands; } });
|
|
120
|
+
Object.defineProperty(exports, "SERVICE_UUID", { enumerable: true, get: function () { return bluetooth_protocol_1.SERVICE_UUID; } });
|
|
121
|
+
Object.defineProperty(exports, "WRITE_CHARACTERISTIC_UUID", { enumerable: true, get: function () { return bluetooth_protocol_1.WRITE_CHARACTERISTIC_UUID; } });
|
|
122
|
+
// Parsers
|
|
123
|
+
Object.defineProperty(exports, "writeCommands", { enumerable: true, get: function () { return bluetooth_protocol_1.writeCommands; } });
|
package/dist/esm/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
/** Edilkamin GATT service UUID */
|
|
15
|
+
export declare const SERVICE_UUID = "0000abf0-0000-1000-8000-00805f9b34fb";
|
|
16
|
+
/** Write characteristic UUID (WRITE NO RESPONSE) */
|
|
17
|
+
export declare const WRITE_CHARACTERISTIC_UUID = "0000abf1-0000-1000-8000-00805f9b34fb";
|
|
18
|
+
/** Notify characteristic UUID (NOTIFY) */
|
|
19
|
+
export declare const NOTIFY_CHARACTERISTIC_UUID = "0000abf2-0000-1000-8000-00805f9b34fb";
|
|
20
|
+
/**
|
|
21
|
+
* Parsed Modbus response from the device.
|
|
22
|
+
*/
|
|
23
|
+
export interface ModbusResponse {
|
|
24
|
+
/** Slave address (always 0x01 for Edilkamin) */
|
|
25
|
+
slaveAddress: number;
|
|
26
|
+
/** Function code (0x03 for read, 0x06 for write) */
|
|
27
|
+
functionCode: number;
|
|
28
|
+
/** Byte count (for read responses) */
|
|
29
|
+
byteCount?: number;
|
|
30
|
+
/** Response data */
|
|
31
|
+
data: Uint8Array;
|
|
32
|
+
/** Whether the response indicates an error */
|
|
33
|
+
isError: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Calculate CRC16-Modbus checksum.
|
|
37
|
+
*
|
|
38
|
+
* @param data - Data to calculate CRC for
|
|
39
|
+
* @returns 2-byte CRC in little-endian order [crcLo, crcHi]
|
|
40
|
+
*/
|
|
41
|
+
export declare const crc16Modbus: (data: Uint8Array) => Uint8Array;
|
|
42
|
+
/**
|
|
43
|
+
* Encrypt data using AES-128-CBC (raw, without PKCS7 padding).
|
|
44
|
+
*
|
|
45
|
+
* This manually applies PKCS7 padding to make input a multiple of 16 bytes,
|
|
46
|
+
* encrypts with Web Crypto, then strips the extra padding block from output.
|
|
47
|
+
*
|
|
48
|
+
* @param plaintext - Data to encrypt (must be 32 bytes)
|
|
49
|
+
* @returns Encrypted data (32 bytes)
|
|
50
|
+
*/
|
|
51
|
+
export declare const aesEncrypt: (plaintext: Uint8Array) => Promise<Uint8Array>;
|
|
52
|
+
/**
|
|
53
|
+
* Decrypt data using AES-128-CBC (raw, handling PKCS7 padding).
|
|
54
|
+
*
|
|
55
|
+
* @param ciphertext - Data to decrypt (must be 32 bytes)
|
|
56
|
+
* @returns Decrypted data (32 bytes)
|
|
57
|
+
*/
|
|
58
|
+
export declare const aesDecrypt: (ciphertext: Uint8Array) => Promise<Uint8Array>;
|
|
59
|
+
/**
|
|
60
|
+
* Build and encrypt a command packet to send to the device.
|
|
61
|
+
*
|
|
62
|
+
* Packet structure (32 bytes before encryption):
|
|
63
|
+
* - Bytes 0-3: Unix timestamp (big-endian)
|
|
64
|
+
* - Bytes 4-19: Fixed key
|
|
65
|
+
* - Bytes 20-25: Modbus command (6 bytes)
|
|
66
|
+
* - Bytes 26-27: CRC16-Modbus of command
|
|
67
|
+
* - Bytes 28-31: Padding [0x04, 0x04, 0x04, 0x04]
|
|
68
|
+
*
|
|
69
|
+
* @param modbusCommand - 6-byte Modbus RTU command
|
|
70
|
+
* @returns 32-byte encrypted packet ready to send via BLE
|
|
71
|
+
*/
|
|
72
|
+
export declare const createPacket: (modbusCommand: Uint8Array) => Promise<Uint8Array>;
|
|
73
|
+
/**
|
|
74
|
+
* Decrypt and parse a response packet from the device.
|
|
75
|
+
*
|
|
76
|
+
* Response structure (32 bytes before decryption):
|
|
77
|
+
* - Bytes 0-3: Unix timestamp
|
|
78
|
+
* - Bytes 4-19: Fixed key
|
|
79
|
+
* - Bytes 20-26: Modbus response (7 bytes)
|
|
80
|
+
* - Bytes 27-28: CRC16-Modbus
|
|
81
|
+
* - Bytes 29-31: Padding [0x03, 0x03, 0x03]
|
|
82
|
+
*
|
|
83
|
+
* @param encrypted - 32-byte encrypted response from BLE notification
|
|
84
|
+
* @returns Parsed Modbus response
|
|
85
|
+
*/
|
|
86
|
+
export declare const parseResponse: (encrypted: Uint8Array) => Promise<ModbusResponse>;
|
|
87
|
+
/**
|
|
88
|
+
* Pre-built Modbus read commands for querying device state.
|
|
89
|
+
* Each command is 6 bytes: [SlaveAddr, FuncCode, RegHi, RegLo, CountHi, CountLo]
|
|
90
|
+
*/
|
|
91
|
+
export declare const readCommands: {
|
|
92
|
+
/** Power state (0=off, 1=on) */
|
|
93
|
+
power: Uint8Array<ArrayBuffer>;
|
|
94
|
+
/** Current ambient temperature (value / 10 = °C) */
|
|
95
|
+
temperature: Uint8Array<ArrayBuffer>;
|
|
96
|
+
/** Target temperature (value / 10 = °C) */
|
|
97
|
+
targetTemperature: Uint8Array<ArrayBuffer>;
|
|
98
|
+
/** Power level (1-5) */
|
|
99
|
+
powerLevel: Uint8Array<ArrayBuffer>;
|
|
100
|
+
/** Fan 1 speed (0=auto, 1-5=speed) */
|
|
101
|
+
fan1Speed: Uint8Array<ArrayBuffer>;
|
|
102
|
+
/** Fan 2 speed (0=auto, 1-5=speed) */
|
|
103
|
+
fan2Speed: Uint8Array<ArrayBuffer>;
|
|
104
|
+
/** Device state code */
|
|
105
|
+
state: Uint8Array<ArrayBuffer>;
|
|
106
|
+
/** Alarm status code */
|
|
107
|
+
alarm: Uint8Array<ArrayBuffer>;
|
|
108
|
+
/** Pellet warning status */
|
|
109
|
+
pelletAlarm: Uint8Array<ArrayBuffer>;
|
|
110
|
+
/** Auto mode (0=manual, 1=auto) */
|
|
111
|
+
autoMode: Uint8Array<ArrayBuffer>;
|
|
112
|
+
/** Standby mode status */
|
|
113
|
+
standby: Uint8Array<ArrayBuffer>;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Builder functions for Modbus write commands.
|
|
117
|
+
* Each function returns a 6-byte command: [SlaveAddr, FuncCode, RegHi, RegLo, ValHi, ValLo]
|
|
118
|
+
*/
|
|
119
|
+
export declare const writeCommands: {
|
|
120
|
+
/**
|
|
121
|
+
* Turn power on or off.
|
|
122
|
+
* @param on - true to turn on, false to turn off
|
|
123
|
+
*/
|
|
124
|
+
setPower: (on: boolean) => Uint8Array;
|
|
125
|
+
/**
|
|
126
|
+
* Set target temperature.
|
|
127
|
+
* @param tempCelsius - Temperature in Celsius (e.g., 21.5)
|
|
128
|
+
*/
|
|
129
|
+
setTemperature: (tempCelsius: number) => Uint8Array;
|
|
130
|
+
/**
|
|
131
|
+
* Set power level.
|
|
132
|
+
* @param level - Power level (1-5)
|
|
133
|
+
*/
|
|
134
|
+
setPowerLevel: (level: number) => Uint8Array;
|
|
135
|
+
/**
|
|
136
|
+
* Set fan 1 speed.
|
|
137
|
+
* @param speed - Fan speed (0=auto, 1-5=manual speed)
|
|
138
|
+
*/
|
|
139
|
+
setFan1Speed: (speed: number) => Uint8Array;
|
|
140
|
+
/**
|
|
141
|
+
* Set fan 2 speed.
|
|
142
|
+
* @param speed - Fan speed (0=auto, 1-5=manual speed)
|
|
143
|
+
*/
|
|
144
|
+
setFan2Speed: (speed: number) => Uint8Array;
|
|
145
|
+
/**
|
|
146
|
+
* Enable or disable auto mode.
|
|
147
|
+
* @param enabled - true to enable auto mode
|
|
148
|
+
*/
|
|
149
|
+
setAutoMode: (enabled: boolean) => Uint8Array;
|
|
150
|
+
/**
|
|
151
|
+
* Enable or disable standby mode.
|
|
152
|
+
* @param enabled - true to enable standby mode
|
|
153
|
+
*/
|
|
154
|
+
setStandby: (enabled: boolean) => Uint8Array;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Parser functions to extract meaningful values from Modbus responses.
|
|
158
|
+
*/
|
|
159
|
+
export declare const parsers: {
|
|
160
|
+
/**
|
|
161
|
+
* Parse boolean response (power state, auto mode, etc.).
|
|
162
|
+
* @param response - Parsed Modbus response
|
|
163
|
+
* @returns true if value is 0x01, false if 0x00
|
|
164
|
+
*/
|
|
165
|
+
boolean: (response: ModbusResponse) => boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Parse temperature response.
|
|
168
|
+
* @param response - Parsed Modbus response
|
|
169
|
+
* @returns Temperature in Celsius
|
|
170
|
+
*/
|
|
171
|
+
temperature: (response: ModbusResponse) => number;
|
|
172
|
+
/**
|
|
173
|
+
* Parse numeric value (power level, fan speed, state code, etc.).
|
|
174
|
+
* @param response - Parsed Modbus response
|
|
175
|
+
* @returns Numeric value
|
|
176
|
+
*/
|
|
177
|
+
number: (response: ModbusResponse) => number;
|
|
178
|
+
};
|