dsc-itv2-client 1.0.7
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/README.md +342 -0
- package/package.json +46 -0
- package/src/ITV2Client.js +502 -0
- package/src/constants.js +106 -0
- package/src/event-handler.js +323 -0
- package/src/examples/README.md +287 -0
- package/src/examples/arm-disarm-example.js +152 -0
- package/src/examples/basic-monitoring.js +82 -0
- package/src/examples/interactive-cli.js +1033 -0
- package/src/index.js +17 -0
- package/src/itv2-crypto.js +310 -0
- package/src/itv2-session.js +1069 -0
- package/src/response-parsers.js +185 -0
- package/src/utils.js +240 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ITV2 Session Handler
|
|
3
|
+
* Based on DSC-TLink reverse engineering
|
|
4
|
+
*
|
|
5
|
+
* Packet Structure:
|
|
6
|
+
* [Integration ID 12 ASCII][0x7E][Frame Content][0x7F]
|
|
7
|
+
*
|
|
8
|
+
* Frame Content (with length/CRC framing):
|
|
9
|
+
* [Length 1-2B][SenderSeq][ReceiverSeq][Command 2B][AppSeq][Payload...][CRC-16 2B]
|
|
10
|
+
*
|
|
11
|
+
* Byte Stuffing:
|
|
12
|
+
* 0x7D → 0x7D 0x00
|
|
13
|
+
* 0x7E → 0x7D 0x01
|
|
14
|
+
* 0x7F → 0x7D 0x02
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { crc16 } from './utils.js';
|
|
18
|
+
import { randomBytes } from 'crypto';
|
|
19
|
+
import {
|
|
20
|
+
parseType1Initializer,
|
|
21
|
+
generateType1Initializer,
|
|
22
|
+
aesEcbEncrypt,
|
|
23
|
+
aesEcbDecrypt,
|
|
24
|
+
transformType1KeyString,
|
|
25
|
+
} from './itv2-crypto.js';
|
|
26
|
+
|
|
27
|
+
// CRC-16 lookup table (CCITT)
|
|
28
|
+
const CRC_TABLE = [
|
|
29
|
+
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
|
|
30
|
+
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
|
|
31
|
+
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
|
|
32
|
+
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
|
|
33
|
+
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
|
|
34
|
+
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
|
|
35
|
+
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
|
|
36
|
+
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
|
|
37
|
+
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
|
|
38
|
+
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
|
|
39
|
+
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
|
|
40
|
+
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
|
|
41
|
+
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
|
|
42
|
+
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
|
|
43
|
+
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
|
|
44
|
+
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
|
|
45
|
+
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
|
|
46
|
+
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
|
|
47
|
+
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
|
|
48
|
+
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
|
|
49
|
+
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
|
|
50
|
+
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
|
|
51
|
+
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
|
|
52
|
+
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
|
|
53
|
+
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
|
|
54
|
+
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
|
|
55
|
+
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
|
|
56
|
+
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
|
|
57
|
+
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
|
|
58
|
+
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
|
|
59
|
+
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
|
|
60
|
+
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Command codes
|
|
64
|
+
export const CMD = {
|
|
65
|
+
SIMPLE_ACK: 0x0000,
|
|
66
|
+
// Zone/Lifestyle notifications (0x02xx)
|
|
67
|
+
LIFESTYLE_ZONE_STATUS: 0x0210,
|
|
68
|
+
TIME_DATE_BROADCAST: 0x0220,
|
|
69
|
+
// Access Level commands (0x04xx)
|
|
70
|
+
ACCESS_LEVEL_ENTER: 0x0400,
|
|
71
|
+
ACCESS_LEVEL_EXIT: 0x0401,
|
|
72
|
+
ACCESS_LEVEL_LEAD_INOUT: 0x0402,
|
|
73
|
+
// Error/Response codes (0x05xx)
|
|
74
|
+
COMMAND_ERROR: 0x0501,
|
|
75
|
+
COMMAND_RESPONSE: 0x0502,
|
|
76
|
+
// Session commands (0x06xx)
|
|
77
|
+
POLL: 0x0600,
|
|
78
|
+
OPEN_SESSION: 0x060A,
|
|
79
|
+
END_SESSION: 0x060B,
|
|
80
|
+
BUFFER_SIZES: 0x060C,
|
|
81
|
+
SOFTWARE_VERSION: 0x060D,
|
|
82
|
+
REQUEST_ACCESS: 0x060E,
|
|
83
|
+
SYSTEM_CAPABILITIES: 0x0613,
|
|
84
|
+
PANEL_STATUS: 0x0614,
|
|
85
|
+
// Configuration commands (0x07xx)
|
|
86
|
+
CONFIG_ENTER: 0x0704,
|
|
87
|
+
CONFIG_ACCESS_CODE_WRAPPER: 0x0703,
|
|
88
|
+
// Module Status commands
|
|
89
|
+
STATUS_REQUEST: 0x0800,
|
|
90
|
+
GLOBAL_STATUS: 0x0810,
|
|
91
|
+
ZONE_STATUS: 0x0811,
|
|
92
|
+
PARTITION_STATUS: 0x0812,
|
|
93
|
+
ZONE_BYPASS_STATUS: 0x0813,
|
|
94
|
+
SYSTEM_TROUBLE_STATUS: 0x0814,
|
|
95
|
+
ALARM_MEMORY_INFO: 0x0815,
|
|
96
|
+
BUS_STATUS: 0x0816,
|
|
97
|
+
TROUBLE_DETAIL: 0x0817,
|
|
98
|
+
DOOR_CHIME_STATUS: 0x0819,
|
|
99
|
+
// Module Control commands
|
|
100
|
+
PARTITION_ARM: 0x0900,
|
|
101
|
+
PARTITION_DISARM: 0x0901,
|
|
102
|
+
COMMAND_OUTPUT: 0x0902,
|
|
103
|
+
// Notifications (unsolicited from panel)
|
|
104
|
+
NOTIFICATION_ARMING: 0x0232,
|
|
105
|
+
NOTIFICATION_PARTITION_READY: 0x0239,
|
|
106
|
+
NOTIFICATION_PARTITION_TROUBLE: 0x023F,
|
|
107
|
+
ZONE_ALARM_STATUS: 0x0840,
|
|
108
|
+
// General notification event
|
|
109
|
+
GENERAL_NOTIFICATION: 0x0230,
|
|
110
|
+
DATA_UPDATE_NOTIFICATION: 0x0231,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const CMD_NAMES = Object.fromEntries(
|
|
114
|
+
Object.entries(CMD).map(([k, v]) => [v, k])
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Calculate CRC-16-CCITT using lookup table
|
|
119
|
+
*/
|
|
120
|
+
function calcCrc16(data) {
|
|
121
|
+
let crc = 0xFFFF;
|
|
122
|
+
for (const b of data) {
|
|
123
|
+
crc = ((crc << 8) ^ CRC_TABLE[(crc >> 8) ^ b]) & 0xFFFF;
|
|
124
|
+
}
|
|
125
|
+
return crc;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Byte stuffing for transmission
|
|
130
|
+
*/
|
|
131
|
+
function stuffBytes(data) {
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const b of data) {
|
|
134
|
+
switch (b) {
|
|
135
|
+
case 0x7D:
|
|
136
|
+
result.push(0x7D, 0x00);
|
|
137
|
+
break;
|
|
138
|
+
case 0x7E:
|
|
139
|
+
result.push(0x7D, 0x01);
|
|
140
|
+
break;
|
|
141
|
+
case 0x7F:
|
|
142
|
+
result.push(0x7D, 0x02);
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
result.push(b);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Buffer.from(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Byte unstuffing for reception
|
|
153
|
+
*/
|
|
154
|
+
function unstuffBytes(data) {
|
|
155
|
+
const result = [];
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < data.length) {
|
|
158
|
+
if (data[i] === 0x7D && i + 1 < data.length) {
|
|
159
|
+
switch (data[i + 1]) {
|
|
160
|
+
case 0x00:
|
|
161
|
+
result.push(0x7D);
|
|
162
|
+
break;
|
|
163
|
+
case 0x01:
|
|
164
|
+
result.push(0x7E);
|
|
165
|
+
break;
|
|
166
|
+
case 0x02:
|
|
167
|
+
result.push(0x7F);
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
throw new Error(`Invalid escape sequence: 0x7D 0x${data[i + 1].toString(16)}`);
|
|
171
|
+
}
|
|
172
|
+
i += 2;
|
|
173
|
+
} else {
|
|
174
|
+
result.push(data[i]);
|
|
175
|
+
i++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Buffer.from(result);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Add length and CRC framing to message
|
|
183
|
+
*/
|
|
184
|
+
function addFraming(message) {
|
|
185
|
+
// Length = message + CRC (does NOT include the length byte itself)
|
|
186
|
+
const length = message.length + 2;
|
|
187
|
+
let framed;
|
|
188
|
+
|
|
189
|
+
if (length > 0x7F) {
|
|
190
|
+
// Two-byte length encoding
|
|
191
|
+
framed = Buffer.alloc(2 + message.length + 2);
|
|
192
|
+
framed[0] = (length >> 8) | 0x80;
|
|
193
|
+
framed[1] = length & 0xFF;
|
|
194
|
+
message.copy(framed, 2);
|
|
195
|
+
} else {
|
|
196
|
+
// Single-byte length encoding
|
|
197
|
+
framed = Buffer.alloc(1 + message.length + 2);
|
|
198
|
+
framed[0] = length;
|
|
199
|
+
message.copy(framed, 1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Calculate CRC on length + message
|
|
203
|
+
const crcData = framed.slice(0, -2);
|
|
204
|
+
const crc = calcCrc16(crcData);
|
|
205
|
+
framed.writeUInt16BE(crc, framed.length - 2);
|
|
206
|
+
|
|
207
|
+
return framed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove length and CRC framing from message
|
|
212
|
+
*/
|
|
213
|
+
function removeFraming(data) {
|
|
214
|
+
let offset = 0;
|
|
215
|
+
let length;
|
|
216
|
+
|
|
217
|
+
// Check if two-byte length
|
|
218
|
+
if (data[0] & 0x80) {
|
|
219
|
+
length = ((data[0] & 0x7F) << 8) | data[1];
|
|
220
|
+
offset = 2;
|
|
221
|
+
} else {
|
|
222
|
+
length = data[0];
|
|
223
|
+
offset = 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Extract message and CRC
|
|
227
|
+
const message = data.slice(offset, offset + length - 2);
|
|
228
|
+
const receivedCrc = data.readUInt16BE(offset + length - 2);
|
|
229
|
+
|
|
230
|
+
// Verify CRC
|
|
231
|
+
const crcData = data.slice(0, offset + length - 2);
|
|
232
|
+
const calculatedCrc = calcCrc16(crcData);
|
|
233
|
+
|
|
234
|
+
if (receivedCrc !== calculatedCrc) {
|
|
235
|
+
throw new Error(`CRC mismatch: received 0x${receivedCrc.toString(16)}, calculated 0x${calculatedCrc.toString(16)}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return message;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parse ITv2 header from message
|
|
243
|
+
*/
|
|
244
|
+
function parseHeader(message) {
|
|
245
|
+
const header = {
|
|
246
|
+
senderSequence: message[0],
|
|
247
|
+
receiverSequence: message[1],
|
|
248
|
+
command: null,
|
|
249
|
+
appSequence: null,
|
|
250
|
+
commandData: null,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (message.length > 2) {
|
|
254
|
+
header.command = message.readUInt16BE(2);
|
|
255
|
+
}
|
|
256
|
+
if (message.length > 4) {
|
|
257
|
+
header.appSequence = message[4];
|
|
258
|
+
}
|
|
259
|
+
if (message.length > 5) {
|
|
260
|
+
header.commandData = message.slice(5);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return header;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build ITv2 header message
|
|
268
|
+
*/
|
|
269
|
+
function buildHeader(senderSeq, receiverSeq, command = null, appSeq = null, commandData = null) {
|
|
270
|
+
const parts = [Buffer.from([senderSeq, receiverSeq])];
|
|
271
|
+
|
|
272
|
+
if (command !== null) {
|
|
273
|
+
const cmdBuf = Buffer.alloc(2);
|
|
274
|
+
cmdBuf.writeUInt16BE(command);
|
|
275
|
+
parts.push(cmdBuf);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (appSeq !== null) {
|
|
279
|
+
parts.push(Buffer.from([appSeq]));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (commandData !== null) {
|
|
283
|
+
parts.push(commandData);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return Buffer.concat(parts);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* ITV2 Session class
|
|
291
|
+
*
|
|
292
|
+
* Key terminology (from SDK analysis):
|
|
293
|
+
* - Integration ID: 12-digit identifier from panel [422], used in packet header
|
|
294
|
+
* - Access Code: 8-digit code from panel [423] for Type 1, or 32-hex from [700] for Type 2
|
|
295
|
+
*
|
|
296
|
+
* Key derivation:
|
|
297
|
+
* - Type 1: Uses Access Code (8 digits) → ASCII bytes repeated twice = 16 bytes
|
|
298
|
+
* - Type 2: Uses Access Code (32 hex digits) → 16 bytes directly
|
|
299
|
+
*/
|
|
300
|
+
export class ITv2Session {
|
|
301
|
+
constructor(integrationId, accessCode, log = console.log) {
|
|
302
|
+
this.integrationId = integrationId; // 12-digit ID for packet header
|
|
303
|
+
this.accessCode = accessCode; // 8-digit (Type 1) or 32-hex (Type 2) for encryption
|
|
304
|
+
this.integrationAccessCode = accessCode; // Alias for compatibility
|
|
305
|
+
this.log = log;
|
|
306
|
+
|
|
307
|
+
// Sequence tracking
|
|
308
|
+
this.localSequence = 0; // Our sequence number
|
|
309
|
+
this.remoteSequence = 0; // Panel's sequence number
|
|
310
|
+
this.appSequence = 0;
|
|
311
|
+
|
|
312
|
+
// Encryption state
|
|
313
|
+
this.sendAesKey = null;
|
|
314
|
+
this.receiveAesKey = null;
|
|
315
|
+
this.sendAesActive = false;
|
|
316
|
+
this.receiveAesActive = false;
|
|
317
|
+
|
|
318
|
+
// Session state
|
|
319
|
+
this.state = 'DISCONNECTED';
|
|
320
|
+
this.remoteOpenSession = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Enable AES encryption for sending
|
|
325
|
+
*/
|
|
326
|
+
enableSendAes(key) {
|
|
327
|
+
this.sendAesKey = key;
|
|
328
|
+
this.sendAesActive = true;
|
|
329
|
+
this.log(`[Session] Send AES enabled: ${key.toString('hex')}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Enable AES decryption for receiving
|
|
334
|
+
*/
|
|
335
|
+
enableReceiveAes(key) {
|
|
336
|
+
this.receiveAesKey = key;
|
|
337
|
+
this.receiveAesActive = true;
|
|
338
|
+
this.log(`[Session] Receive AES enabled: ${key.toString('hex')}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Disable all AES encryption/decryption (for session reset)
|
|
343
|
+
*/
|
|
344
|
+
disableAes() {
|
|
345
|
+
this.sendAesKey = null;
|
|
346
|
+
this.sendAesActive = false;
|
|
347
|
+
this.receiveAesKey = null;
|
|
348
|
+
this.receiveAesActive = false;
|
|
349
|
+
this.encryptionType = 0;
|
|
350
|
+
this.log(`[Session] AES disabled (session reset)`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build a complete packet ready for transmission
|
|
355
|
+
*
|
|
356
|
+
* Encryption order (must match parsePacket):
|
|
357
|
+
* TX: message → framing (add length/CRC) → encrypt → byte stuff
|
|
358
|
+
* RX: byte unstuff → decrypt → remove framing (verify CRC) → message
|
|
359
|
+
*/
|
|
360
|
+
buildPacket(message) {
|
|
361
|
+
// Add length/CRC framing FIRST
|
|
362
|
+
let framed = addFraming(message);
|
|
363
|
+
|
|
364
|
+
// Encrypt the FRAMED data (including length byte and CRC) if enabled
|
|
365
|
+
if (this.sendAesActive) {
|
|
366
|
+
this.log(`[Session] Pre-encrypt framed: ${framed.toString('hex')}`);
|
|
367
|
+
framed = aesEcbEncrypt(this.sendAesKey, framed);
|
|
368
|
+
this.log(`[Session] Encrypted framed: ${framed.toString('hex')}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Byte stuff the payload
|
|
372
|
+
const stuffedPayload = stuffBytes(framed);
|
|
373
|
+
|
|
374
|
+
// Build full packet WITHOUT integration ID
|
|
375
|
+
// The panel sends WITH integration ID, but SDK sends WITHOUT it
|
|
376
|
+
return Buffer.concat([
|
|
377
|
+
Buffer.from([0x7E]),
|
|
378
|
+
stuffedPayload,
|
|
379
|
+
Buffer.from([0x7F]),
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Parse a received packet
|
|
385
|
+
*/
|
|
386
|
+
parsePacket(data) {
|
|
387
|
+
// Find frame markers
|
|
388
|
+
const startIdx = data.indexOf(0x7E);
|
|
389
|
+
const endIdx = data.lastIndexOf(0x7F);
|
|
390
|
+
|
|
391
|
+
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
392
|
+
throw new Error('Invalid packet: missing frame markers');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Extract and unstuff header
|
|
396
|
+
const stuffedHeader = data.slice(0, startIdx);
|
|
397
|
+
const header = unstuffBytes(stuffedHeader);
|
|
398
|
+
|
|
399
|
+
// Extract and unstuff payload
|
|
400
|
+
const stuffedPayload = data.slice(startIdx + 1, endIdx);
|
|
401
|
+
let framedPayload = unstuffBytes(stuffedPayload);
|
|
402
|
+
|
|
403
|
+
// For encrypted packets, the ENTIRE framed payload (including length byte) is encrypted
|
|
404
|
+
// Decrypt BEFORE removing framing if AES is active and payload is block-aligned
|
|
405
|
+
if (this.receiveAesActive && framedPayload.length >= 16 && framedPayload.length % 16 === 0) {
|
|
406
|
+
this.log(`[Session] Decrypting ${framedPayload.length} byte payload: ${framedPayload.toString('hex')}`);
|
|
407
|
+
framedPayload = aesEcbDecrypt(this.receiveAesKey, framedPayload);
|
|
408
|
+
this.log(`[Session] Decrypted: ${framedPayload.toString('hex')}`);
|
|
409
|
+
} else if (!this.receiveAesActive && framedPayload.length >= 16 && framedPayload.length % 16 === 0 && this.accessCode) {
|
|
410
|
+
// Panel may send encrypted data before handshake completes, using Access Code-derived key
|
|
411
|
+
// Try to decrypt with the pre-shared key if payload looks encrypted (16-byte aligned)
|
|
412
|
+
this.log(`[Session] Attempting pre-handshake decryption with Access Code key`);
|
|
413
|
+
this.log(`[Session] Encrypted payload: ${framedPayload.toString('hex')}`);
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const preSharedKey = transformType1KeyString(this.accessCode, 'ascii');
|
|
417
|
+
this.log(`[Session] Pre-shared key: ${preSharedKey.toString('hex')}`);
|
|
418
|
+
|
|
419
|
+
const decrypted = aesEcbDecrypt(preSharedKey, framedPayload);
|
|
420
|
+
this.log(`[Session] Decrypted: ${decrypted.toString('hex')}`);
|
|
421
|
+
|
|
422
|
+
// Validate: first byte should be a reasonable length (< 128 for single-byte length)
|
|
423
|
+
// or have high bit set for two-byte length but result in valid range
|
|
424
|
+
const firstByte = decrypted[0];
|
|
425
|
+
const isValidLength = (firstByte & 0x80) === 0
|
|
426
|
+
? (firstByte > 0 && firstByte <= framedPayload.length)
|
|
427
|
+
: ((((firstByte & 0x7F) << 8) | decrypted[1]) <= framedPayload.length);
|
|
428
|
+
|
|
429
|
+
if (isValidLength) {
|
|
430
|
+
this.log(`[Session] Pre-handshake decryption successful (valid length byte: 0x${firstByte.toString(16)})`);
|
|
431
|
+
framedPayload = decrypted;
|
|
432
|
+
} else {
|
|
433
|
+
this.log(`[Session] Decrypted data has invalid length byte 0x${firstByte.toString(16)}, using original payload`);
|
|
434
|
+
}
|
|
435
|
+
} catch (err) {
|
|
436
|
+
this.log(`[Session] Pre-handshake decryption failed: ${err.message}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Remove framing (length/CRC)
|
|
441
|
+
let message = removeFraming(framedPayload);
|
|
442
|
+
|
|
443
|
+
// Parse header
|
|
444
|
+
const parsed = parseHeader(message);
|
|
445
|
+
parsed.integrationId = header.toString('ascii');
|
|
446
|
+
parsed.raw = data;
|
|
447
|
+
|
|
448
|
+
// Update remote sequence
|
|
449
|
+
this.remoteSequence = parsed.senderSequence;
|
|
450
|
+
|
|
451
|
+
return parsed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build SimpleAck response
|
|
456
|
+
* SDK shows ACK format: [length][our_sender_seq][remote_seq_being_acked][CRC]
|
|
457
|
+
* The sender sequence is our current sequence (doesn't increment for ACK)
|
|
458
|
+
* The receiver sequence is the remote sequence being acknowledged
|
|
459
|
+
*/
|
|
460
|
+
buildSimpleAck() {
|
|
461
|
+
// Use our current localSequence as sender (SDK shows this, not always 0)
|
|
462
|
+
const message = buildHeader(this.localSequence, this.remoteSequence);
|
|
463
|
+
return this.buildPacket(message);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Build command message
|
|
468
|
+
*/
|
|
469
|
+
buildCommand(command, commandData = null) {
|
|
470
|
+
this.localSequence = (this.localSequence + 1) & 0xFF;
|
|
471
|
+
this.appSequence = (this.appSequence + 1) & 0xFF;
|
|
472
|
+
|
|
473
|
+
const message = buildHeader(
|
|
474
|
+
this.localSequence,
|
|
475
|
+
this.remoteSequence,
|
|
476
|
+
command,
|
|
477
|
+
this.appSequence,
|
|
478
|
+
commandData
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
return this.buildPacket(message);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Build COMMAND_RESPONSE with success code
|
|
486
|
+
*/
|
|
487
|
+
buildCommandResponse(responseCode = 0x00) {
|
|
488
|
+
return this.buildCommand(CMD.COMMAND_RESPONSE, Buffer.from([responseCode]));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Build COMMAND_RESPONSE echoing the app sequence from the request
|
|
493
|
+
* This matches the SDK behavior where COMMAND_RESPONSE payload is [echoed_app_seq, response_code]
|
|
494
|
+
*
|
|
495
|
+
* @param {number} appSeq - App sequence to echo from the request
|
|
496
|
+
* @param {number} responseCode - Response code (0x00 = success)
|
|
497
|
+
*/
|
|
498
|
+
buildCommandResponseWithAppSeq(appSeq, responseCode = 0x00) {
|
|
499
|
+
this.localSequence = (this.localSequence + 1) & 0xFF;
|
|
500
|
+
|
|
501
|
+
// SDK shows COMMAND_RESPONSE has NO app sequence of its own,
|
|
502
|
+
// just [echoed_first_byte, response_code] as payload
|
|
503
|
+
const message = buildHeader(
|
|
504
|
+
this.localSequence,
|
|
505
|
+
this.remoteSequence,
|
|
506
|
+
CMD.COMMAND_RESPONSE,
|
|
507
|
+
null, // No app sequence in COMMAND_RESPONSE itself
|
|
508
|
+
Buffer.from([appSeq, responseCode])
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
return this.buildPacket(message);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build OPEN_SESSION message (echo back panel's session info)
|
|
516
|
+
* NOTE: OPEN_SESSION does NOT have an app sequence byte - it goes directly to payload
|
|
517
|
+
*/
|
|
518
|
+
buildOpenSessionResponse(openSessionData) {
|
|
519
|
+
this.localSequence = (this.localSequence + 1) & 0xFF;
|
|
520
|
+
|
|
521
|
+
// OPEN_SESSION format: [sender][receiver][cmd 2B][payload...] - NO app sequence!
|
|
522
|
+
const message = buildHeader(
|
|
523
|
+
this.localSequence,
|
|
524
|
+
this.remoteSequence,
|
|
525
|
+
CMD.OPEN_SESSION,
|
|
526
|
+
null, // NO app sequence for OPEN_SESSION
|
|
527
|
+
openSessionData
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return this.buildPacket(message);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Build REQUEST_ACCESS message for Type 1 encryption
|
|
535
|
+
*/
|
|
536
|
+
buildRequestAccess() {
|
|
537
|
+
const { initializer, localKey } = generateType1Initializer(this.integrationAccessCode);
|
|
538
|
+
this.pendingReceiveKey = localKey;
|
|
539
|
+
// Payload format: [length byte][initializer data]
|
|
540
|
+
const payload = Buffer.concat([Buffer.from([initializer.length]), initializer]);
|
|
541
|
+
return this.buildCommand(CMD.REQUEST_ACCESS, payload);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Handle incoming OPEN_SESSION
|
|
546
|
+
*/
|
|
547
|
+
handleOpenSession(header) {
|
|
548
|
+
const data = header.commandData;
|
|
549
|
+
if (!data || data.length < 13) {
|
|
550
|
+
this.log(`[Session] OPEN_SESSION payload too short: ${data?.length || 0} bytes`);
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// OpenSession structure based on DSC-TLink:
|
|
555
|
+
// [DeviceType 1B][DeviceID 2B][FirmwareVer 2B][ProtocolVer 2B][TxBuf 2B][RxBuf 2B][Unknown 2B][EncType 1B]
|
|
556
|
+
// Total: 14 bytes minimum
|
|
557
|
+
const openSession = {
|
|
558
|
+
deviceType: data[0],
|
|
559
|
+
deviceId: data.slice(1, 3),
|
|
560
|
+
firmwareVersion: data.slice(3, 5),
|
|
561
|
+
protocolVersion: data.slice(5, 7),
|
|
562
|
+
txBufferSize: data.readUInt16BE(7),
|
|
563
|
+
rxBufferSize: data.readUInt16BE(9),
|
|
564
|
+
unknown: data.slice(11, 13),
|
|
565
|
+
encryptionType: data.length > 13 ? data[13] : 1, // Default to Type 1
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
this.log(`[Session] OPEN_SESSION received:`);
|
|
569
|
+
this.log(` Device Type: ${openSession.deviceType}`);
|
|
570
|
+
this.log(` Device ID: ${openSession.deviceId.toString('hex')}`);
|
|
571
|
+
this.log(` Firmware: ${openSession.firmwareVersion.toString('hex')}`);
|
|
572
|
+
this.log(` Protocol: ${openSession.protocolVersion.toString('hex')}`);
|
|
573
|
+
this.log(` TX Buffer: ${openSession.txBufferSize}`);
|
|
574
|
+
this.log(` RX Buffer: ${openSession.rxBufferSize}`);
|
|
575
|
+
this.log(` Encryption Type: ${openSession.encryptionType}`);
|
|
576
|
+
|
|
577
|
+
this.remoteOpenSession = openSession;
|
|
578
|
+
return openSession;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Handle incoming REQUEST_ACCESS
|
|
583
|
+
*/
|
|
584
|
+
handleRequestAccess(header) {
|
|
585
|
+
const data = header.commandData;
|
|
586
|
+
if (!data) {
|
|
587
|
+
this.log(`[Session] REQUEST_ACCESS: no payload`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Payload format: [length byte][initializer data]
|
|
592
|
+
const length = data[0];
|
|
593
|
+
const initializer = data.slice(1, 1 + length);
|
|
594
|
+
|
|
595
|
+
this.log(`[Session] REQUEST_ACCESS: ${length} byte initializer`);
|
|
596
|
+
this.log(` Initializer: ${initializer.toString('hex')}`);
|
|
597
|
+
|
|
598
|
+
if (length === 48) {
|
|
599
|
+
// Type 1 encryption - try both Access Code and Integration ID for key derivation
|
|
600
|
+
try {
|
|
601
|
+
const remoteKey = parseType1Initializer(this.accessCode, initializer, this.integrationId);
|
|
602
|
+
this.log(`[Session] Type 1 remote key derived: ${remoteKey.toString('hex')}`);
|
|
603
|
+
return { type: 1, key: remoteKey };
|
|
604
|
+
} catch (err) {
|
|
605
|
+
this.log(`[Session] Type 1 key derivation failed: ${err.message}`);
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
} else if (length === 16) {
|
|
609
|
+
// Type 2 encryption
|
|
610
|
+
try {
|
|
611
|
+
// For Type 2, the access code should be 32 hex chars (16 bytes)
|
|
612
|
+
// If we have an 8-digit code, expand it: "12345678" -> "12345678123456781234567812345678"
|
|
613
|
+
let accessCode32 = this.accessCode;
|
|
614
|
+
if (accessCode32.length === 8) {
|
|
615
|
+
accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (accessCode32.length !== 32) {
|
|
619
|
+
this.log(`[Session] Type 2 requires 32-digit access code, got ${accessCode32.length}`);
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Type 2: AES-ECB encrypt the initializer with the access code
|
|
624
|
+
// The result is the key for encrypting messages TO the panel
|
|
625
|
+
const key = Buffer.from(accessCode32, 'hex');
|
|
626
|
+
const remoteKey = aesEcbEncrypt(key, initializer);
|
|
627
|
+
|
|
628
|
+
this.log(`[Session] Type 2 remote key derived: ${remoteKey.toString('hex')}`);
|
|
629
|
+
return { type: 2, key: remoteKey };
|
|
630
|
+
} catch (err) {
|
|
631
|
+
this.log(`[Session] Type 2 key derivation failed: ${err.message}`);
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Build REQUEST_ACCESS message for Type 1 encryption (48-byte initializer)
|
|
641
|
+
* Uses the 48-byte format: [16 check bytes][32 ciphertext]
|
|
642
|
+
*
|
|
643
|
+
* IMPORTANT: Type 1 key derivation uses Integration ID (first 8 digits),
|
|
644
|
+
* interpreted as hex and repeated 4 times. NOT ASCII encoding!
|
|
645
|
+
* Example: "250228754876" → "25022875" → hex bytes 0x25,0x02,0x28,0x75 × 4
|
|
646
|
+
*
|
|
647
|
+
* NOTE: Based on SDK analysis, frames remain PLAINTEXT. The initializer payload
|
|
648
|
+
* contains internally encrypted data.
|
|
649
|
+
*/
|
|
650
|
+
buildRequestAccessType1() {
|
|
651
|
+
// SDK analysis shows ASYMMETRIC key usage:
|
|
652
|
+
// - Panel encrypts with Integration ID key (Type1InitDeviceKey)
|
|
653
|
+
// - SDK encrypts with Access Code key (Type1InitServerKey)
|
|
654
|
+
// - Panel decrypts incoming with Access Code key
|
|
655
|
+
//
|
|
656
|
+
// Key derivation: CHexHelper::ToBytes converts hex string to bytes, repeated to 16 bytes
|
|
657
|
+
// For "28754876" → [0x23, 0x42, 0x53, 0x52] × 4 = 16 bytes
|
|
658
|
+
const { initializer, localKey } = generateType1Initializer(this.accessCode, this.integrationId, 'hex');
|
|
659
|
+
|
|
660
|
+
// The localKey is our RECEIVE key - panel will encrypt responses with it
|
|
661
|
+
this.pendingReceiveKey = localKey;
|
|
662
|
+
this.encryptionType = 1;
|
|
663
|
+
|
|
664
|
+
this.log(`[Session] Type 1 REQUEST_ACCESS: 48-byte initializer`);
|
|
665
|
+
this.log(`[Session] Using access code: ${this.accessCode} (hex method)`);
|
|
666
|
+
this.log(`[Session] Initializer: ${initializer.toString('hex')}`);
|
|
667
|
+
this.log(`[Session] Pending receive key: ${localKey.toString('hex')}`);
|
|
668
|
+
|
|
669
|
+
// Payload format: [length byte (0x30=48)][48-byte initializer]
|
|
670
|
+
const commandData = Buffer.concat([Buffer.from([initializer.length]), initializer]);
|
|
671
|
+
|
|
672
|
+
// Increment local sequence for this command
|
|
673
|
+
this.localSequence = (this.localSequence + 1) & 0xFF;
|
|
674
|
+
|
|
675
|
+
// SDK trace shows REQUEST_ACCESS uses appSeq = 0x02 (hardcoded)
|
|
676
|
+
// SDK packet: 38 04 02 06 0E 02 30 22 85 ...
|
|
677
|
+
// The 0x02 after command 06 0E is the appSeq
|
|
678
|
+
const appSeq = 0x02;
|
|
679
|
+
this.appSequence = appSeq; // Keep internal state synchronized
|
|
680
|
+
|
|
681
|
+
this.log(`[Session] REQUEST_ACCESS using localSeq=${this.localSequence}, appSeq=${appSeq}`);
|
|
682
|
+
|
|
683
|
+
const message = buildHeader(
|
|
684
|
+
this.localSequence,
|
|
685
|
+
this.remoteSequence,
|
|
686
|
+
CMD.REQUEST_ACCESS,
|
|
687
|
+
appSeq,
|
|
688
|
+
commandData
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
return this.buildPacket(message);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Build a packet WITHOUT AES encryption (for REQUEST_ACCESS)
|
|
696
|
+
*/
|
|
697
|
+
buildPacketUnencrypted(message) {
|
|
698
|
+
// Add length/CRC framing
|
|
699
|
+
const framed = addFraming(message);
|
|
700
|
+
|
|
701
|
+
// Byte stuff
|
|
702
|
+
const stuffedHeader = stuffBytes(Buffer.from(this.integrationId, 'ascii'));
|
|
703
|
+
const stuffedPayload = stuffBytes(framed);
|
|
704
|
+
|
|
705
|
+
// Build full packet (no encryption)
|
|
706
|
+
return Buffer.concat([
|
|
707
|
+
stuffedHeader,
|
|
708
|
+
Buffer.from([0x7E]),
|
|
709
|
+
stuffedPayload,
|
|
710
|
+
Buffer.from([0x7F]),
|
|
711
|
+
]);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Build REQUEST_ACCESS message for Type 2 encryption
|
|
716
|
+
* @param {string} masterCode - Optional master code to include in payload
|
|
717
|
+
*/
|
|
718
|
+
buildRequestAccessType2(masterCode = null) {
|
|
719
|
+
// Generate 16 random bytes as our initializer
|
|
720
|
+
const initializer = randomBytes(16);
|
|
721
|
+
|
|
722
|
+
// Derive our receive key using same transform
|
|
723
|
+
let accessCode32 = this.integrationAccessCode;
|
|
724
|
+
if (accessCode32.length === 8) {
|
|
725
|
+
accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
|
|
726
|
+
}
|
|
727
|
+
const key = Buffer.from(accessCode32, 'hex');
|
|
728
|
+
this.pendingReceiveKey = aesEcbEncrypt(key, initializer);
|
|
729
|
+
|
|
730
|
+
// Build payload with initializer
|
|
731
|
+
let payload;
|
|
732
|
+
if (masterCode) {
|
|
733
|
+
// Include master code in BCD format after initializer
|
|
734
|
+
// "5555" -> [0x55, 0x55]
|
|
735
|
+
const codeStr = masterCode.toString();
|
|
736
|
+
const codeBytes = Buffer.alloc(3, 0xFF);
|
|
737
|
+
for (let i = 0; i < codeStr.length; i += 2) {
|
|
738
|
+
const high = parseInt(codeStr[i], 10);
|
|
739
|
+
const low = i + 1 < codeStr.length ? parseInt(codeStr[i + 1], 10) : 0x0F;
|
|
740
|
+
codeBytes[Math.floor(i / 2)] = (high << 4) | low;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Payload format: [length byte][initializer data][code length][code bytes]
|
|
744
|
+
payload = Buffer.concat([
|
|
745
|
+
Buffer.from([initializer.length]),
|
|
746
|
+
initializer,
|
|
747
|
+
Buffer.from([codeStr.length]),
|
|
748
|
+
codeBytes
|
|
749
|
+
]);
|
|
750
|
+
this.log(`[Session] REQUEST_ACCESS with master code: ${masterCode}`);
|
|
751
|
+
} else {
|
|
752
|
+
// Payload format: [length byte][initializer data]
|
|
753
|
+
payload = Buffer.concat([Buffer.from([initializer.length]), initializer]);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return this.buildCommand(CMD.REQUEST_ACCESS, payload);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Build status request command
|
|
761
|
+
* @param {number} statusType - Type of status to request:
|
|
762
|
+
* 0x10 = Global Status (0x0810)
|
|
763
|
+
* 0x11 = Zone Status (0x0811)
|
|
764
|
+
* 0x12 = Partition Status (0x0812)
|
|
765
|
+
* 0x13 = Zone Bypass Status (0x0813)
|
|
766
|
+
* 0x14 = System Trouble Status (0x0814)
|
|
767
|
+
* 0x15 = Alarm Memory Info (0x0815)
|
|
768
|
+
* 0x16 = Bus Status (0x0816)
|
|
769
|
+
* 0x17 = Trouble Detail (0x0817)
|
|
770
|
+
* 0x19 = Door Chime Status (0x0819)
|
|
771
|
+
* @param {number} partitionOrZone - Partition or zone number (0 = all)
|
|
772
|
+
*/
|
|
773
|
+
buildStatusRequest(statusType, partitionOrZone = 0) {
|
|
774
|
+
// Payload: [status type byte][partition/zone number]
|
|
775
|
+
const payload = Buffer.from([statusType, partitionOrZone]);
|
|
776
|
+
return this.buildCommand(CMD.STATUS_REQUEST, payload);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Build global status request
|
|
781
|
+
*/
|
|
782
|
+
buildGlobalStatusRequest() {
|
|
783
|
+
// Send GLOBAL_STATUS (0x0810) command directly, not wrapped in STATUS_REQUEST
|
|
784
|
+
return this.buildCommand(CMD.GLOBAL_STATUS);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Build zone status request
|
|
789
|
+
* @param {number} zone - Zone number (0 = all zones)
|
|
790
|
+
*/
|
|
791
|
+
buildZoneStatusRequest(zone = 0) {
|
|
792
|
+
// Send ZONE_STATUS (0x0811) command directly with zone number
|
|
793
|
+
const payload = zone > 0 ? Buffer.from([zone]) : Buffer.alloc(0);
|
|
794
|
+
return this.buildCommand(CMD.ZONE_STATUS, payload);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Build partition status request
|
|
799
|
+
* @param {number} partition - Partition number (0 = all partitions)
|
|
800
|
+
*/
|
|
801
|
+
buildPartitionStatusRequest(partition = 0) {
|
|
802
|
+
// Send PARTITION_STATUS (0x0812) command directly with partition number
|
|
803
|
+
const payload = partition > 0 ? Buffer.from([partition]) : Buffer.alloc(0);
|
|
804
|
+
return this.buildCommand(CMD.PARTITION_STATUS, payload);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Build zone bypass status request
|
|
809
|
+
* @param {number} zone - Zone number (0 = all zones)
|
|
810
|
+
*/
|
|
811
|
+
buildZoneBypassStatusRequest(zone = 0) {
|
|
812
|
+
return this.buildStatusRequest(0x13, zone);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Build system trouble status request
|
|
817
|
+
*/
|
|
818
|
+
buildTroubleStatusRequest() {
|
|
819
|
+
return this.buildStatusRequest(0x14, 0);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Build ACCESS_LEVEL_ENTER command to authenticate with access code
|
|
824
|
+
* @param {string} accessCode - The access code (e.g., "5555" for master code)
|
|
825
|
+
* @param {number} partition - Partition number (0 = all partitions)
|
|
826
|
+
* @param {number} accessLevel - Access level (0=User, 1=Installer, 2=Master)
|
|
827
|
+
* @param {string} format - 'ascii', 'bcd', or 'binary' - how to encode the code
|
|
828
|
+
*/
|
|
829
|
+
buildAccessLevelEnter(accessCode, partition = 1, accessLevel = 0, format = 'bcd') {
|
|
830
|
+
const codeStr = accessCode.toString();
|
|
831
|
+
let codeBytes;
|
|
832
|
+
|
|
833
|
+
if (format === 'bcd') {
|
|
834
|
+
// BCD encoding: each digit as a nibble, pairs of digits per byte
|
|
835
|
+
// "5555" -> [0x55, 0x55, 0xFF, 0xFF] (FF = unused)
|
|
836
|
+
codeBytes = Buffer.alloc(3, 0xFF); // 6 digits max = 3 bytes, unused = 0xFF
|
|
837
|
+
for (let i = 0; i < codeStr.length; i += 2) {
|
|
838
|
+
const high = parseInt(codeStr[i], 10);
|
|
839
|
+
const low = i + 1 < codeStr.length ? parseInt(codeStr[i + 1], 10) : 0x0F;
|
|
840
|
+
codeBytes[Math.floor(i / 2)] = (high << 4) | low;
|
|
841
|
+
}
|
|
842
|
+
} else if (format === 'ascii') {
|
|
843
|
+
// ASCII encoding: each digit as its ASCII code
|
|
844
|
+
codeBytes = Buffer.alloc(6, 0);
|
|
845
|
+
for (let i = 0; i < Math.min(codeStr.length, 6); i++) {
|
|
846
|
+
codeBytes[i] = codeStr.charCodeAt(i);
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
// Binary encoding: each digit as its numeric value
|
|
850
|
+
codeBytes = Buffer.alloc(6, 0xFF);
|
|
851
|
+
for (let i = 0; i < Math.min(codeStr.length, 6); i++) {
|
|
852
|
+
codeBytes[i] = parseInt(codeStr[i], 10);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Payload format: [partition][access level][code length][code bytes...]
|
|
857
|
+
const payload = Buffer.concat([
|
|
858
|
+
Buffer.from([partition, accessLevel, codeStr.length]),
|
|
859
|
+
codeBytes
|
|
860
|
+
]);
|
|
861
|
+
|
|
862
|
+
this.log(`[Session] ACCESS_LEVEL_ENTER: partition=${partition}, level=${accessLevel}, code=${codeStr}, format=${format}`);
|
|
863
|
+
this.log(`[Session] Payload: ${payload.toString('hex')}`);
|
|
864
|
+
|
|
865
|
+
return this.buildCommand(CMD.ACCESS_LEVEL_ENTER, payload);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Build ACCESS_LEVEL_EXIT command to de-authenticate
|
|
870
|
+
* @param {number} partition - Partition number
|
|
871
|
+
*/
|
|
872
|
+
buildAccessLevelExit(partition = 1) {
|
|
873
|
+
const payload = Buffer.from([partition]);
|
|
874
|
+
return this.buildCommand(CMD.ACCESS_LEVEL_EXIT, payload);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Build POLL command for keepalive
|
|
879
|
+
*/
|
|
880
|
+
buildPoll() {
|
|
881
|
+
return this.buildCommand(CMD.POLL);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Build END_SESSION command to gracefully close the session
|
|
886
|
+
*/
|
|
887
|
+
buildEndSession() {
|
|
888
|
+
return this.buildCommand(CMD.END_SESSION);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Build SYSTEM_CAPABILITIES request
|
|
893
|
+
*/
|
|
894
|
+
buildSystemCapabilitiesRequest() {
|
|
895
|
+
return this.buildCommand(CMD.SYSTEM_CAPABILITIES);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Parse partition status response
|
|
900
|
+
* @param {Buffer} data - Payload from PARTITION_STATUS response
|
|
901
|
+
*/
|
|
902
|
+
parsePartitionStatus(data) {
|
|
903
|
+
if (!data || data.length < 2) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const partition = data[0];
|
|
908
|
+
const statusByte = data[1];
|
|
909
|
+
|
|
910
|
+
// Status byte bit meanings (based on DSC protocol):
|
|
911
|
+
const status = {
|
|
912
|
+
partition,
|
|
913
|
+
armed: !!(statusByte & 0x01), // Bit 0: Armed
|
|
914
|
+
stayArmed: !!(statusByte & 0x02), // Bit 1: Stay Armed
|
|
915
|
+
awayArmed: !!(statusByte & 0x04), // Bit 2: Away Armed
|
|
916
|
+
alarm: !!(statusByte & 0x08), // Bit 3: Alarm
|
|
917
|
+
trouble: !!(statusByte & 0x10), // Bit 4: Trouble
|
|
918
|
+
ready: !!(statusByte & 0x20), // Bit 5: Ready
|
|
919
|
+
exitDelay: !!(statusByte & 0x40), // Bit 6: Exit Delay
|
|
920
|
+
entryDelay: !!(statusByte & 0x80), // Bit 7: Entry Delay
|
|
921
|
+
rawStatus: statusByte,
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// Additional bytes may contain more info
|
|
925
|
+
if (data.length > 2) {
|
|
926
|
+
status.additionalData = data.slice(2);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return status;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Parse zone status response
|
|
934
|
+
* @param {Buffer} data - Payload from ZONE_STATUS response
|
|
935
|
+
*/
|
|
936
|
+
parseZoneStatus(data) {
|
|
937
|
+
if (!data || data.length < 2) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const zone = data[0];
|
|
942
|
+
const statusByte = data[1];
|
|
943
|
+
|
|
944
|
+
// Zone status bit meanings:
|
|
945
|
+
const status = {
|
|
946
|
+
zone,
|
|
947
|
+
open: !!(statusByte & 0x01), // Bit 0: Zone Open
|
|
948
|
+
tamper: !!(statusByte & 0x02), // Bit 1: Tamper
|
|
949
|
+
fault: !!(statusByte & 0x04), // Bit 2: Fault
|
|
950
|
+
lowBattery: !!(statusByte & 0x08), // Bit 3: Low Battery
|
|
951
|
+
supervision: !!(statusByte & 0x10), // Bit 4: Supervision Loss
|
|
952
|
+
alarm: !!(statusByte & 0x20), // Bit 5: In Alarm
|
|
953
|
+
bypassed: !!(statusByte & 0x40), // Bit 6: Bypassed
|
|
954
|
+
armed: !!(statusByte & 0x80), // Bit 7: Armed
|
|
955
|
+
rawStatus: statusByte,
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// Additional bytes may contain zone type, etc.
|
|
959
|
+
if (data.length > 2) {
|
|
960
|
+
status.additionalData = data.slice(2);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return status;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Parse global status response
|
|
968
|
+
* @param {Buffer} data - Payload from GLOBAL_STATUS response
|
|
969
|
+
*/
|
|
970
|
+
parseGlobalStatus(data) {
|
|
971
|
+
if (!data || data.length < 1) {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
rawData: data,
|
|
977
|
+
// Global status typically contains system-wide flags
|
|
978
|
+
// Exact format depends on panel model
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ============ Arm/Disarm Commands ============
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Build PARTITION_ARM command
|
|
986
|
+
* @param {number} partition - Partition number (1-8)
|
|
987
|
+
* @param {number} armMode - Arming mode:
|
|
988
|
+
* 0 = Stay (perimeter only)
|
|
989
|
+
* 1 = Away (full arm)
|
|
990
|
+
* 2 = No Entry Delay
|
|
991
|
+
* 3 = Force Arm (bypass open zones)
|
|
992
|
+
* @param {string} accessCode - Master/user code (e.g., "5555")
|
|
993
|
+
*/
|
|
994
|
+
buildPartitionArm(partition, armMode, accessCode) {
|
|
995
|
+
const codeStr = accessCode.toString();
|
|
996
|
+
|
|
997
|
+
// Payload format based on SDK decompiled code:
|
|
998
|
+
// [Partition VarLength][ArmMode 1B][AccessCode BCD 3B]
|
|
999
|
+
// Variable length: 1 byte for partition < 253
|
|
1000
|
+
|
|
1001
|
+
// BCD encoding: each pair of digits as one byte
|
|
1002
|
+
// "5555" -> 0x55 0x55 0xFF (3 bytes, unused nibbles = 0xF)
|
|
1003
|
+
const codeBytes = Buffer.alloc(3, 0xFF);
|
|
1004
|
+
for (let i = 0; i < codeStr.length && i < 6; i += 2) {
|
|
1005
|
+
const high = parseInt(codeStr[i], 10);
|
|
1006
|
+
const low = i + 1 < codeStr.length ? parseInt(codeStr[i + 1], 10) : 0x0F;
|
|
1007
|
+
codeBytes[Math.floor(i / 2)] = (high << 4) | low;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Build payload: [partition 1B][mode 1B][bcd code 3B]
|
|
1011
|
+
const payload = Buffer.concat([
|
|
1012
|
+
Buffer.from([partition]), // 1 byte for partition number
|
|
1013
|
+
Buffer.from([armMode]),
|
|
1014
|
+
codeBytes
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
this.log(`[Session] PARTITION_ARM: partition=${partition}, mode=${armMode}, code=${codeStr}`);
|
|
1018
|
+
this.log(`[Session] Payload: ${payload.toString('hex')}`);
|
|
1019
|
+
|
|
1020
|
+
return this.buildCommand(CMD.PARTITION_ARM, payload);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Build PARTITION_DISARM command
|
|
1025
|
+
* @param {number} partition - Partition number (1-8)
|
|
1026
|
+
* @param {string} accessCode - Master/user code (e.g., "5555")
|
|
1027
|
+
*/
|
|
1028
|
+
buildPartitionDisarm(partition, accessCode) {
|
|
1029
|
+
const codeStr = accessCode.toString();
|
|
1030
|
+
|
|
1031
|
+
// Payload format based on SDK decompiled code:
|
|
1032
|
+
// [Partition VarLength][AccessCode BCD 3B]
|
|
1033
|
+
// Variable length: 1 byte for partition < 253
|
|
1034
|
+
|
|
1035
|
+
// BCD encoding: each pair of digits as one byte
|
|
1036
|
+
// "5555" -> 0x55 0x55 0xFF (3 bytes, unused nibbles = 0xF)
|
|
1037
|
+
const codeBytes = Buffer.alloc(3, 0xFF);
|
|
1038
|
+
for (let i = 0; i < codeStr.length && i < 6; i += 2) {
|
|
1039
|
+
const high = parseInt(codeStr[i], 10);
|
|
1040
|
+
const low = i + 1 < codeStr.length ? parseInt(codeStr[i + 1], 10) : 0x0F;
|
|
1041
|
+
codeBytes[Math.floor(i / 2)] = (high << 4) | low;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Build payload: [partition 1B][bcd code 3B]
|
|
1045
|
+
const payload = Buffer.concat([
|
|
1046
|
+
Buffer.from([partition]), // 1 byte for partition number
|
|
1047
|
+
codeBytes
|
|
1048
|
+
]);
|
|
1049
|
+
|
|
1050
|
+
this.log(`[Session] PARTITION_DISARM: partition=${partition}, code=${codeStr}`);
|
|
1051
|
+
this.log(`[Session] Payload: ${payload.toString('hex')}`);
|
|
1052
|
+
|
|
1053
|
+
return this.buildCommand(CMD.PARTITION_DISARM, payload);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Build command output activation (PGM trigger)
|
|
1058
|
+
* @param {number} partition - Partition number
|
|
1059
|
+
* @param {number} outputNumber - Command output number (1-4)
|
|
1060
|
+
*/
|
|
1061
|
+
buildCommandOutput(partition, outputNumber) {
|
|
1062
|
+
const payload = Buffer.alloc(3);
|
|
1063
|
+
payload.writeUInt16BE(partition, 0);
|
|
1064
|
+
payload.writeUInt8(outputNumber, 2);
|
|
1065
|
+
|
|
1066
|
+
this.log(`[Session] COMMAND_OUTPUT: partition=${partition}, output=${outputNumber}`);
|
|
1067
|
+
return this.buildCommand(CMD.COMMAND_OUTPUT, payload);
|
|
1068
|
+
}
|
|
1069
|
+
}
|