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.
@@ -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
+ }