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
|
@@ -10,7 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
import { strict as assert } from "assert";
|
|
11
11
|
import pako from "pako";
|
|
12
12
|
import sinon from "sinon";
|
|
13
|
-
import { configure, createAuthService } from "../src/library";
|
|
13
|
+
import { configure, createAuthService, deriveUsageAnalytics, } from "../src/library";
|
|
14
14
|
import { API_URL } from "./constants";
|
|
15
15
|
/**
|
|
16
16
|
* Helper to create a gzip-compressed Buffer object for testing.
|
|
@@ -1009,6 +1009,139 @@ describe("library", () => {
|
|
|
1009
1009
|
assert.equal(result.lastMaintenanceDate, null);
|
|
1010
1010
|
}));
|
|
1011
1011
|
});
|
|
1012
|
+
describe("deriveUsageAnalytics", () => {
|
|
1013
|
+
const mockDeviceInfoForDerive = {
|
|
1014
|
+
status: {
|
|
1015
|
+
commands: {
|
|
1016
|
+
power: false,
|
|
1017
|
+
},
|
|
1018
|
+
temperatures: {
|
|
1019
|
+
enviroment: 20,
|
|
1020
|
+
set_air: 21,
|
|
1021
|
+
get_air: 20,
|
|
1022
|
+
set_water: 40,
|
|
1023
|
+
get_water: 35,
|
|
1024
|
+
},
|
|
1025
|
+
counters: {
|
|
1026
|
+
service_time: 1108,
|
|
1027
|
+
},
|
|
1028
|
+
flags: {
|
|
1029
|
+
is_pellet_in_reserve: false,
|
|
1030
|
+
},
|
|
1031
|
+
pellet: {
|
|
1032
|
+
autonomy_time: 180,
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
nvm: {
|
|
1036
|
+
user_parameters: {
|
|
1037
|
+
language: 1,
|
|
1038
|
+
is_auto: false,
|
|
1039
|
+
is_fahrenheit: false,
|
|
1040
|
+
is_sound_active: false,
|
|
1041
|
+
enviroment_1_temperature: 19,
|
|
1042
|
+
enviroment_2_temperature: 20,
|
|
1043
|
+
enviroment_3_temperature: 20,
|
|
1044
|
+
manual_power: 1,
|
|
1045
|
+
fan_1_ventilation: 3,
|
|
1046
|
+
fan_2_ventilation: 0,
|
|
1047
|
+
fan_3_ventilation: 0,
|
|
1048
|
+
is_standby_active: false,
|
|
1049
|
+
standby_waiting_time: 60,
|
|
1050
|
+
},
|
|
1051
|
+
total_counters: {
|
|
1052
|
+
power_ons: 278,
|
|
1053
|
+
p1_working_time: 833,
|
|
1054
|
+
p2_working_time: 15,
|
|
1055
|
+
p3_working_time: 19,
|
|
1056
|
+
p4_working_time: 8,
|
|
1057
|
+
p5_working_time: 17,
|
|
1058
|
+
},
|
|
1059
|
+
service_counters: {
|
|
1060
|
+
p1_working_time: 100,
|
|
1061
|
+
p2_working_time: 10,
|
|
1062
|
+
p3_working_time: 5,
|
|
1063
|
+
p4_working_time: 2,
|
|
1064
|
+
p5_working_time: 1,
|
|
1065
|
+
},
|
|
1066
|
+
regeneration: {
|
|
1067
|
+
time: 0,
|
|
1068
|
+
last_intervention: 1577836800,
|
|
1069
|
+
daylight_time_flag: 0,
|
|
1070
|
+
blackout_counter: 43,
|
|
1071
|
+
airkare_working_hours_counter: 0,
|
|
1072
|
+
},
|
|
1073
|
+
alarms_log: {
|
|
1074
|
+
number: 6,
|
|
1075
|
+
index: 6,
|
|
1076
|
+
alarms: [],
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
it("should derive analytics from device info without API call", () => {
|
|
1081
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1082
|
+
const analytics = deriveUsageAnalytics(mockDeviceInfoForDerive);
|
|
1083
|
+
assert.equal(analytics.totalPowerOns, 278);
|
|
1084
|
+
assert.equal(analytics.totalOperatingHours, 892); // 833+15+19+8+17
|
|
1085
|
+
assert.equal(analytics.blackoutCount, 43);
|
|
1086
|
+
assert.equal(analytics.alarmCount, 6);
|
|
1087
|
+
});
|
|
1088
|
+
it("should calculate power distribution correctly", () => {
|
|
1089
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1090
|
+
const analytics = deriveUsageAnalytics(mockDeviceInfoForDerive);
|
|
1091
|
+
// P1: 833/892 ≈ 93.4%
|
|
1092
|
+
assert.ok(analytics.powerDistribution.p1 > 93);
|
|
1093
|
+
assert.ok(analytics.powerDistribution.p1 < 94);
|
|
1094
|
+
// Sum should be 100%
|
|
1095
|
+
const sum = Object.values(analytics.powerDistribution).reduce((a, b) => a + b, 0);
|
|
1096
|
+
assert.ok(Math.abs(sum - 100) < 0.001);
|
|
1097
|
+
});
|
|
1098
|
+
it("should handle zero operating hours", () => {
|
|
1099
|
+
const zeroHoursInfo = Object.assign(Object.assign({}, mockDeviceInfoForDerive), { nvm: Object.assign(Object.assign({}, mockDeviceInfoForDerive.nvm), { total_counters: {
|
|
1100
|
+
power_ons: 0,
|
|
1101
|
+
p1_working_time: 0,
|
|
1102
|
+
p2_working_time: 0,
|
|
1103
|
+
p3_working_time: 0,
|
|
1104
|
+
p4_working_time: 0,
|
|
1105
|
+
p5_working_time: 0,
|
|
1106
|
+
} }) });
|
|
1107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1108
|
+
const analytics = deriveUsageAnalytics(zeroHoursInfo);
|
|
1109
|
+
assert.deepEqual(analytics.powerDistribution, {
|
|
1110
|
+
p1: 0,
|
|
1111
|
+
p2: 0,
|
|
1112
|
+
p3: 0,
|
|
1113
|
+
p4: 0,
|
|
1114
|
+
p5: 0,
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
it("should respect custom service threshold", () => {
|
|
1118
|
+
const analytics = deriveUsageAnalytics(
|
|
1119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1120
|
+
mockDeviceInfoForDerive, 100);
|
|
1121
|
+
// 118 hours since service >= 100 threshold
|
|
1122
|
+
assert.equal(analytics.serviceStatus.isServiceDue, true);
|
|
1123
|
+
assert.equal(analytics.serviceStatus.serviceThresholdHours, 100);
|
|
1124
|
+
});
|
|
1125
|
+
it("should use default threshold of 2000 hours", () => {
|
|
1126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1127
|
+
const analytics = deriveUsageAnalytics(mockDeviceInfoForDerive);
|
|
1128
|
+
assert.equal(analytics.serviceStatus.serviceThresholdHours, 2000);
|
|
1129
|
+
assert.equal(analytics.serviceStatus.isServiceDue, false); // 118 < 2000
|
|
1130
|
+
});
|
|
1131
|
+
it("should convert last_intervention timestamp to Date", () => {
|
|
1132
|
+
var _a;
|
|
1133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1134
|
+
const analytics = deriveUsageAnalytics(mockDeviceInfoForDerive);
|
|
1135
|
+
assert.ok(analytics.lastMaintenanceDate instanceof Date);
|
|
1136
|
+
assert.equal((_a = analytics.lastMaintenanceDate) === null || _a === void 0 ? void 0 : _a.getTime(), 1577836800 * 1000);
|
|
1137
|
+
});
|
|
1138
|
+
it("should return null for lastMaintenanceDate when timestamp is 0", () => {
|
|
1139
|
+
const noMaintenanceInfo = Object.assign(Object.assign({}, mockDeviceInfoForDerive), { nvm: Object.assign(Object.assign({}, mockDeviceInfoForDerive.nvm), { regeneration: Object.assign(Object.assign({}, mockDeviceInfoForDerive.nvm.regeneration), { last_intervention: 0 }) }) });
|
|
1140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1141
|
+
const analytics = deriveUsageAnalytics(noMaintenanceInfo);
|
|
1142
|
+
assert.equal(analytics.lastMaintenanceDate, null);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1012
1145
|
describe("Error Handling", () => {
|
|
1013
1146
|
const errorTests = [
|
|
1014
1147
|
{ status: 400, statusText: "Bad Request" },
|
package/package.json
CHANGED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { strict as assert } from "assert";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
aesDecrypt,
|
|
5
|
+
aesEncrypt,
|
|
6
|
+
crc16Modbus,
|
|
7
|
+
createPacket,
|
|
8
|
+
ModbusResponse,
|
|
9
|
+
NOTIFY_CHARACTERISTIC_UUID,
|
|
10
|
+
parseResponse,
|
|
11
|
+
parsers,
|
|
12
|
+
readCommands,
|
|
13
|
+
SERVICE_UUID,
|
|
14
|
+
WRITE_CHARACTERISTIC_UUID,
|
|
15
|
+
writeCommands,
|
|
16
|
+
} from "./bluetooth-protocol";
|
|
17
|
+
|
|
18
|
+
describe("bluetooth-protocol", () => {
|
|
19
|
+
describe("constants", () => {
|
|
20
|
+
it("exports SERVICE_UUID", () => {
|
|
21
|
+
assert.equal(SERVICE_UUID, "0000abf0-0000-1000-8000-00805f9b34fb");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("exports WRITE_CHARACTERISTIC_UUID", () => {
|
|
25
|
+
assert.equal(
|
|
26
|
+
WRITE_CHARACTERISTIC_UUID,
|
|
27
|
+
"0000abf1-0000-1000-8000-00805f9b34fb",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("exports NOTIFY_CHARACTERISTIC_UUID", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
NOTIFY_CHARACTERISTIC_UUID,
|
|
34
|
+
"0000abf2-0000-1000-8000-00805f9b34fb",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("crc16Modbus", () => {
|
|
40
|
+
it("calculates correct CRC for power-on command", () => {
|
|
41
|
+
const command = new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]);
|
|
42
|
+
const crc = crc16Modbus(command);
|
|
43
|
+
assert.equal(crc.length, 2);
|
|
44
|
+
// CRC is returned as [crcLo, crcHi]
|
|
45
|
+
assert.ok(crc[0] >= 0 && crc[0] <= 255);
|
|
46
|
+
assert.ok(crc[1] >= 0 && crc[1] <= 255);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns 2 bytes for any input", () => {
|
|
50
|
+
const testCases = [
|
|
51
|
+
new Uint8Array([0x01, 0x03, 0x05, 0x25, 0x00, 0x01]),
|
|
52
|
+
new Uint8Array([0x01, 0x06, 0x04, 0x40, 0x00, 0x03]),
|
|
53
|
+
new Uint8Array([0x00]),
|
|
54
|
+
new Uint8Array([0xff, 0xff, 0xff]),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const data of testCases) {
|
|
58
|
+
const crc = crc16Modbus(data);
|
|
59
|
+
assert.equal(
|
|
60
|
+
crc.length,
|
|
61
|
+
2,
|
|
62
|
+
`CRC for ${data.toString()} should be 2 bytes`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("produces different CRCs for different data", () => {
|
|
68
|
+
const crc1 = crc16Modbus(
|
|
69
|
+
new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]),
|
|
70
|
+
);
|
|
71
|
+
const crc2 = crc16Modbus(
|
|
72
|
+
new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x00]),
|
|
73
|
+
);
|
|
74
|
+
// CRCs should be different for different data
|
|
75
|
+
assert.ok(crc1[0] !== crc2[0] || crc1[1] !== crc2[1]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("aesEncrypt/aesDecrypt", () => {
|
|
80
|
+
it("roundtrip returns original data", async () => {
|
|
81
|
+
const original = new Uint8Array(32).fill(0x42);
|
|
82
|
+
const encrypted = await aesEncrypt(original);
|
|
83
|
+
const decrypted = await aesDecrypt(encrypted);
|
|
84
|
+
assert.deepEqual(decrypted, original);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("produces 32-byte output for 32-byte input", async () => {
|
|
88
|
+
const input = new Uint8Array(32);
|
|
89
|
+
const encrypted = await aesEncrypt(input);
|
|
90
|
+
assert.equal(encrypted.length, 32);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("produces different output for different input", async () => {
|
|
94
|
+
const input1 = new Uint8Array(32).fill(0x00);
|
|
95
|
+
const input2 = new Uint8Array(32).fill(0xff);
|
|
96
|
+
const encrypted1 = await aesEncrypt(input1);
|
|
97
|
+
const encrypted2 = await aesEncrypt(input2);
|
|
98
|
+
// At least some bytes should be different
|
|
99
|
+
let different = false;
|
|
100
|
+
for (let i = 0; i < 32; i++) {
|
|
101
|
+
if (encrypted1[i] !== encrypted2[i]) {
|
|
102
|
+
different = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
assert.ok(different, "Different inputs should produce different outputs");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("encrypted data is different from plaintext", async () => {
|
|
110
|
+
const original = new Uint8Array(32).fill(0xaa);
|
|
111
|
+
const encrypted = await aesEncrypt(original);
|
|
112
|
+
// Encrypted should not equal original
|
|
113
|
+
let same = true;
|
|
114
|
+
for (let i = 0; i < 32; i++) {
|
|
115
|
+
if (encrypted[i] !== original[i]) {
|
|
116
|
+
same = false;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
assert.ok(!same, "Encrypted data should differ from original");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("createPacket", () => {
|
|
125
|
+
it("produces 32-byte encrypted packet", async () => {
|
|
126
|
+
const command = readCommands.power;
|
|
127
|
+
const packet = await createPacket(command);
|
|
128
|
+
assert.equal(packet.length, 32);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects commands not exactly 6 bytes", async () => {
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => createPacket(new Uint8Array([0x01, 0x02])),
|
|
134
|
+
/must be exactly 6 bytes/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("rejects empty commands", async () => {
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
() => createPacket(new Uint8Array([])),
|
|
141
|
+
/must be exactly 6 bytes/,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rejects 7-byte commands", async () => {
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
() =>
|
|
148
|
+
createPacket(
|
|
149
|
+
new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]),
|
|
150
|
+
),
|
|
151
|
+
/must be exactly 6 bytes/,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("produces different packets for different commands", async () => {
|
|
156
|
+
const packet1 = await createPacket(writeCommands.setPower(true));
|
|
157
|
+
const packet2 = await createPacket(writeCommands.setPower(false));
|
|
158
|
+
// Packets should be different (different command bytes)
|
|
159
|
+
let different = false;
|
|
160
|
+
for (let i = 0; i < 32; i++) {
|
|
161
|
+
if (packet1[i] !== packet2[i]) {
|
|
162
|
+
different = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
assert.ok(
|
|
167
|
+
different,
|
|
168
|
+
"Different commands should produce different packets",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("parseResponse", () => {
|
|
174
|
+
it("rejects responses not 32 bytes", async () => {
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
() => parseResponse(new Uint8Array(16)),
|
|
177
|
+
/must be exactly 32 bytes/,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("can decrypt and parse an encrypted packet", async () => {
|
|
182
|
+
// Create a command packet and try to parse it
|
|
183
|
+
// This tests that encrypt/decrypt work together
|
|
184
|
+
const command = readCommands.power;
|
|
185
|
+
const encrypted = await createPacket(command);
|
|
186
|
+
|
|
187
|
+
// Parse the response (it's not a valid response but should decrypt)
|
|
188
|
+
const parsed = await parseResponse(encrypted);
|
|
189
|
+
|
|
190
|
+
// Should have parsed something
|
|
191
|
+
assert.ok(typeof parsed.slaveAddress === "number");
|
|
192
|
+
assert.ok(typeof parsed.functionCode === "number");
|
|
193
|
+
assert.ok(typeof parsed.isError === "boolean");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("readCommands", () => {
|
|
198
|
+
it("all commands are 6 bytes", () => {
|
|
199
|
+
Object.entries(readCommands).forEach(([name, cmd]) => {
|
|
200
|
+
assert.equal(cmd.length, 6, `${name} command must be 6 bytes`);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("all commands use slave address 0x01", () => {
|
|
205
|
+
Object.entries(readCommands).forEach(([name, cmd]) => {
|
|
206
|
+
assert.equal(cmd[0], 0x01, `${name} should use slave address 0x01`);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("all commands use function code 0x03", () => {
|
|
211
|
+
Object.entries(readCommands).forEach(([name, cmd]) => {
|
|
212
|
+
assert.equal(cmd[1], 0x03, `${name} should use function code 0x03`);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("power command has correct register address", () => {
|
|
217
|
+
assert.equal(readCommands.power[2], 0x05);
|
|
218
|
+
assert.equal(readCommands.power[3], 0x29);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("temperature command has correct register address", () => {
|
|
222
|
+
assert.equal(readCommands.temperature[2], 0x05);
|
|
223
|
+
assert.equal(readCommands.temperature[3], 0x25);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("writeCommands", () => {
|
|
228
|
+
it("setPower(true) produces correct bytes", () => {
|
|
229
|
+
const cmd = writeCommands.setPower(true);
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
cmd,
|
|
232
|
+
new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]),
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("setPower(false) produces correct bytes", () => {
|
|
237
|
+
const cmd = writeCommands.setPower(false);
|
|
238
|
+
assert.deepEqual(
|
|
239
|
+
cmd,
|
|
240
|
+
new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x00]),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("setTemperature encodes correctly", () => {
|
|
245
|
+
const cmd = writeCommands.setTemperature(21.5);
|
|
246
|
+
// 21.5 * 10 = 215 = 0x00D7
|
|
247
|
+
assert.equal(cmd[0], 0x01); // slave address
|
|
248
|
+
assert.equal(cmd[1], 0x06); // function code
|
|
249
|
+
assert.equal(cmd[2], 0x05); // register hi
|
|
250
|
+
assert.equal(cmd[3], 0x25); // register lo
|
|
251
|
+
assert.equal(cmd[4], 0x00); // value hi
|
|
252
|
+
assert.equal(cmd[5], 0xd7); // value lo
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("setTemperature handles whole numbers", () => {
|
|
256
|
+
const cmd = writeCommands.setTemperature(20);
|
|
257
|
+
// 20 * 10 = 200 = 0x00C8
|
|
258
|
+
assert.equal(cmd[4], 0x00);
|
|
259
|
+
assert.equal(cmd[5], 0xc8);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("setTemperature handles high temperatures", () => {
|
|
263
|
+
const cmd = writeCommands.setTemperature(30);
|
|
264
|
+
// 30 * 10 = 300 = 0x012C
|
|
265
|
+
assert.equal(cmd[4], 0x01);
|
|
266
|
+
assert.equal(cmd[5], 0x2c);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("setPowerLevel validates range", () => {
|
|
270
|
+
assert.throws(() => writeCommands.setPowerLevel(0), /must be 1-5/);
|
|
271
|
+
assert.throws(() => writeCommands.setPowerLevel(6), /must be 1-5/);
|
|
272
|
+
assert.doesNotThrow(() => writeCommands.setPowerLevel(1));
|
|
273
|
+
assert.doesNotThrow(() => writeCommands.setPowerLevel(3));
|
|
274
|
+
assert.doesNotThrow(() => writeCommands.setPowerLevel(5));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("setPowerLevel produces correct bytes", () => {
|
|
278
|
+
const cmd = writeCommands.setPowerLevel(3);
|
|
279
|
+
assert.equal(cmd[0], 0x01);
|
|
280
|
+
assert.equal(cmd[1], 0x06);
|
|
281
|
+
assert.equal(cmd[2], 0x04);
|
|
282
|
+
assert.equal(cmd[3], 0x40);
|
|
283
|
+
assert.equal(cmd[4], 0x00);
|
|
284
|
+
assert.equal(cmd[5], 0x03);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("setFan1Speed validates range", () => {
|
|
288
|
+
assert.throws(() => writeCommands.setFan1Speed(-1), /must be 0-5/);
|
|
289
|
+
assert.throws(() => writeCommands.setFan1Speed(6), /must be 0-5/);
|
|
290
|
+
assert.doesNotThrow(() => writeCommands.setFan1Speed(0)); // auto
|
|
291
|
+
assert.doesNotThrow(() => writeCommands.setFan1Speed(5));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("setFan2Speed validates range", () => {
|
|
295
|
+
assert.throws(() => writeCommands.setFan2Speed(-1), /must be 0-5/);
|
|
296
|
+
assert.throws(() => writeCommands.setFan2Speed(6), /must be 0-5/);
|
|
297
|
+
assert.doesNotThrow(() => writeCommands.setFan2Speed(0));
|
|
298
|
+
assert.doesNotThrow(() => writeCommands.setFan2Speed(5));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("setAutoMode produces correct bytes", () => {
|
|
302
|
+
const cmdOn = writeCommands.setAutoMode(true);
|
|
303
|
+
assert.equal(cmdOn[5], 0x01);
|
|
304
|
+
|
|
305
|
+
const cmdOff = writeCommands.setAutoMode(false);
|
|
306
|
+
assert.equal(cmdOff[5], 0x00);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("setStandby produces correct bytes", () => {
|
|
310
|
+
const cmdOn = writeCommands.setStandby(true);
|
|
311
|
+
assert.equal(cmdOn[5], 0x01);
|
|
312
|
+
|
|
313
|
+
const cmdOff = writeCommands.setStandby(false);
|
|
314
|
+
assert.equal(cmdOff[5], 0x00);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("all write commands are 6 bytes", () => {
|
|
318
|
+
const commands = [
|
|
319
|
+
writeCommands.setPower(true),
|
|
320
|
+
writeCommands.setTemperature(21),
|
|
321
|
+
writeCommands.setPowerLevel(3),
|
|
322
|
+
writeCommands.setFan1Speed(2),
|
|
323
|
+
writeCommands.setFan2Speed(2),
|
|
324
|
+
writeCommands.setAutoMode(true),
|
|
325
|
+
writeCommands.setStandby(false),
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
commands.forEach((cmd, i) => {
|
|
329
|
+
assert.equal(cmd.length, 6, `Command ${i} should be 6 bytes`);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("all write commands use function code 0x06", () => {
|
|
334
|
+
const commands = [
|
|
335
|
+
writeCommands.setPower(true),
|
|
336
|
+
writeCommands.setTemperature(21),
|
|
337
|
+
writeCommands.setPowerLevel(3),
|
|
338
|
+
writeCommands.setFan1Speed(2),
|
|
339
|
+
writeCommands.setFan2Speed(2),
|
|
340
|
+
writeCommands.setAutoMode(true),
|
|
341
|
+
writeCommands.setStandby(false),
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
commands.forEach((cmd, i) => {
|
|
345
|
+
assert.equal(
|
|
346
|
+
cmd[1],
|
|
347
|
+
0x06,
|
|
348
|
+
`Command ${i} should use function code 0x06`,
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("parsers", () => {
|
|
355
|
+
it("boolean parser returns true for 0x01", () => {
|
|
356
|
+
const response: ModbusResponse = {
|
|
357
|
+
slaveAddress: 1,
|
|
358
|
+
functionCode: 0x03,
|
|
359
|
+
byteCount: 2,
|
|
360
|
+
data: new Uint8Array([0x00, 0x01]),
|
|
361
|
+
isError: false,
|
|
362
|
+
};
|
|
363
|
+
assert.equal(parsers.boolean(response), true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("boolean parser returns false for 0x00", () => {
|
|
367
|
+
const response: ModbusResponse = {
|
|
368
|
+
slaveAddress: 1,
|
|
369
|
+
functionCode: 0x03,
|
|
370
|
+
byteCount: 2,
|
|
371
|
+
data: new Uint8Array([0x00, 0x00]),
|
|
372
|
+
isError: false,
|
|
373
|
+
};
|
|
374
|
+
assert.equal(parsers.boolean(response), false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("temperature parser divides by 10", () => {
|
|
378
|
+
const response: ModbusResponse = {
|
|
379
|
+
slaveAddress: 1,
|
|
380
|
+
functionCode: 0x03,
|
|
381
|
+
byteCount: 2,
|
|
382
|
+
data: new Uint8Array([0x00, 0xd7]), // 215
|
|
383
|
+
isError: false,
|
|
384
|
+
};
|
|
385
|
+
assert.equal(parsers.temperature(response), 21.5);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("temperature parser handles high temperatures", () => {
|
|
389
|
+
const response: ModbusResponse = {
|
|
390
|
+
slaveAddress: 1,
|
|
391
|
+
functionCode: 0x03,
|
|
392
|
+
byteCount: 2,
|
|
393
|
+
data: new Uint8Array([0x01, 0x2c]), // 300
|
|
394
|
+
isError: false,
|
|
395
|
+
};
|
|
396
|
+
assert.equal(parsers.temperature(response), 30);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("number parser returns big-endian value", () => {
|
|
400
|
+
const response: ModbusResponse = {
|
|
401
|
+
slaveAddress: 1,
|
|
402
|
+
functionCode: 0x03,
|
|
403
|
+
byteCount: 2,
|
|
404
|
+
data: new Uint8Array([0x00, 0x03]), // power level 3
|
|
405
|
+
isError: false,
|
|
406
|
+
};
|
|
407
|
+
assert.equal(parsers.number(response), 3);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("number parser handles larger values", () => {
|
|
411
|
+
const response: ModbusResponse = {
|
|
412
|
+
slaveAddress: 1,
|
|
413
|
+
functionCode: 0x03,
|
|
414
|
+
byteCount: 2,
|
|
415
|
+
data: new Uint8Array([0x01, 0x00]), // 256
|
|
416
|
+
isError: false,
|
|
417
|
+
};
|
|
418
|
+
assert.equal(parsers.number(response), 256);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("boolean parser throws on error response", () => {
|
|
422
|
+
const errorResponse: ModbusResponse = {
|
|
423
|
+
slaveAddress: 1,
|
|
424
|
+
functionCode: 0x03,
|
|
425
|
+
data: new Uint8Array([0x02]), // error code
|
|
426
|
+
isError: true,
|
|
427
|
+
};
|
|
428
|
+
assert.throws(() => parsers.boolean(errorResponse), /Modbus error: 2/);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("temperature parser throws on error response", () => {
|
|
432
|
+
const errorResponse: ModbusResponse = {
|
|
433
|
+
slaveAddress: 1,
|
|
434
|
+
functionCode: 0x03,
|
|
435
|
+
data: new Uint8Array([0x03]),
|
|
436
|
+
isError: true,
|
|
437
|
+
};
|
|
438
|
+
assert.throws(
|
|
439
|
+
() => parsers.temperature(errorResponse),
|
|
440
|
+
/Modbus error: 3/,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("number parser throws on error response", () => {
|
|
445
|
+
const errorResponse: ModbusResponse = {
|
|
446
|
+
slaveAddress: 1,
|
|
447
|
+
functionCode: 0x03,
|
|
448
|
+
data: new Uint8Array([0x04]),
|
|
449
|
+
isError: true,
|
|
450
|
+
};
|
|
451
|
+
assert.throws(() => parsers.number(errorResponse), /Modbus error: 4/);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("integration", () => {
|
|
456
|
+
it("full roundtrip: create packet, decrypt, check structure", async () => {
|
|
457
|
+
// Create a power-on command
|
|
458
|
+
const command = writeCommands.setPower(true);
|
|
459
|
+
assert.deepEqual(
|
|
460
|
+
command,
|
|
461
|
+
new Uint8Array([0x01, 0x06, 0x03, 0x1c, 0x00, 0x01]),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Create encrypted packet
|
|
465
|
+
const packet = await createPacket(command);
|
|
466
|
+
assert.equal(packet.length, 32);
|
|
467
|
+
|
|
468
|
+
// Decrypt it back (as if we received it)
|
|
469
|
+
// Note: createPacket encrypts with padding, parseResponse expects response format
|
|
470
|
+
// This is not a true response but tests the encryption layer
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("all read commands can create valid packets", async () => {
|
|
474
|
+
for (const [name, command] of Object.entries(readCommands)) {
|
|
475
|
+
const packet = await createPacket(command);
|
|
476
|
+
assert.equal(packet.length, 32, `${name} should create 32-byte packet`);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("all write commands can create valid packets", async () => {
|
|
481
|
+
const commands = [
|
|
482
|
+
{ name: "setPower", cmd: writeCommands.setPower(true) },
|
|
483
|
+
{ name: "setTemperature", cmd: writeCommands.setTemperature(21) },
|
|
484
|
+
{ name: "setPowerLevel", cmd: writeCommands.setPowerLevel(3) },
|
|
485
|
+
{ name: "setFan1Speed", cmd: writeCommands.setFan1Speed(2) },
|
|
486
|
+
{ name: "setFan2Speed", cmd: writeCommands.setFan2Speed(2) },
|
|
487
|
+
{ name: "setAutoMode", cmd: writeCommands.setAutoMode(true) },
|
|
488
|
+
{ name: "setStandby", cmd: writeCommands.setStandby(false) },
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
for (const { name, cmd } of commands) {
|
|
492
|
+
const packet = await createPacket(cmd);
|
|
493
|
+
assert.equal(packet.length, 32, `${name} should create 32-byte packet`);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
});
|