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,1033 @@
1
+ /**
2
+ * DSC TL280R ITV2 Protocol Server
3
+ *
4
+ * This server handles the ITV2 protocol handshake and session management
5
+ * for DSC alarm panels using TL280R communicators.
6
+ */
7
+
8
+ import dgram from 'dgram';
9
+ import readline from 'readline';
10
+ import { ITv2Session, CMD, CMD_NAMES } from '../itv2-session.js';
11
+ import { parseType1Initializer, type2InitializerTransform } from '../itv2-crypto.js';
12
+ import { EventHandler } from '../event-handler.js';
13
+ import { parseZoneStatus, formatZoneStatus, parsePartitionStatus, formatPartitionStatus, parseCommandResponse, parseCommandError } from '../response-parsers.js';
14
+
15
+ // Configuration
16
+ const INTEGRATION_ID = process.env.INTEGRATION_ID || '123123123123';
17
+ const ACCESS_CODE = process.env.ACCESS_CODE || '12345678';
18
+ const MASTER_CODE = process.env.MASTER_CODE || '5555';
19
+ const UDP_PORT = parseInt(process.env.UDP_PORT || '3073', 10);
20
+
21
+ // Session tracking
22
+ let session = null;
23
+ let panelAddress = null;
24
+ let panelPort = null;
25
+ let detectedEncryptionType = null; // Set during handshake: 1 or 2
26
+ let handshakeState = 'WAITING';
27
+ let testCommandSent = false;
28
+
29
+ // Event Handler for zone/partition state management
30
+ const eventHandler = new EventHandler(log);
31
+
32
+ // Register event listeners
33
+ eventHandler.on('zone:status', (status) => {
34
+ console.log(`\n[EVENT] Zone ${status.zoneNumber} status update: ${status.open ? 'OPEN' : 'CLOSED'}`);
35
+ });
36
+
37
+ eventHandler.on('zone:alarm', (alarm) => {
38
+ console.log(`\n[EVENT] *** ZONE ${alarm.zoneNumber} ALARM! ***`);
39
+ });
40
+
41
+ eventHandler.on('partition:status', (status) => {
42
+ console.log(`\n[EVENT] Partition ${status.partitionNumber} status: ${status.armedStateName}`);
43
+ });
44
+
45
+ eventHandler.on('partition:change', (change) => {
46
+ console.log(`\n[EVENT] Partition ${change.partition} changed: ${change.previousState || 'UNKNOWN'} -> ${change.newState}`);
47
+ });
48
+
49
+ // UDP Server
50
+ const server = dgram.createSocket('udp4');
51
+
52
+ function log(msg) {
53
+ console.log(`[${new Date().toISOString()}] ${msg}`);
54
+ }
55
+
56
+ function hexDump(buffer, prefix = '') {
57
+ const lines = [];
58
+ for (let i = 0; i < buffer.length; i += 16) {
59
+ const slice = buffer.slice(i, Math.min(i + 16, buffer.length));
60
+ const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
61
+ const ascii = Array.from(slice).map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.').join('');
62
+ lines.push(`${prefix}${i.toString(16).padStart(4, '0')} ${hex.padEnd(48)} |${ascii}|`);
63
+ }
64
+ console.log(lines.join('\n'));
65
+ }
66
+
67
+ function sendPacket(packet) {
68
+ if (!panelAddress || !panelPort) {
69
+ log('[Error] No panel address/port set');
70
+ return;
71
+ }
72
+ log(`\n[UDP] TX to ${panelAddress}:${panelPort} (${packet.length} bytes)`);
73
+ hexDump(packet);
74
+ server.send(packet, panelPort, panelAddress);
75
+ }
76
+
77
+ function sendTestZoneQuery() {
78
+ if (testCommandSent) return;
79
+ testCommandSent = true;
80
+
81
+ log('\n[Test] Session is stable, authenticating first...');
82
+ const authPayload = Buffer.from(MASTER_CODE.padStart(6, '0').slice(0, 6), 'ascii');
83
+ const authPacket = session.buildCommand(CMD.ACCESS_LEVEL_ENTER, authPayload);
84
+ sendPacket(authPacket);
85
+
86
+ // Wait for authentication to complete, then query system info
87
+ setTimeout(() => {
88
+ log('\n[Test] Sending SYSTEM_CAPABILITIES query...');
89
+ const capQuery = session.buildSystemCapabilitiesRequest();
90
+ sendPacket(capQuery);
91
+
92
+ setTimeout(() => {
93
+ log('\n[Test] Sending SOFTWARE_VERSION query...');
94
+ const verQuery = session.buildCommand(CMD.SOFTWARE_VERSION);
95
+ sendPacket(verQuery);
96
+ }, 2000);
97
+
98
+ setTimeout(() => {
99
+ log('\n[Test] Sending PANEL_STATUS query...');
100
+ const panelQuery = session.buildCommand(CMD.PANEL_STATUS);
101
+ sendPacket(panelQuery);
102
+ }, 4000);
103
+
104
+ setTimeout(() => {
105
+ log('\n[Test] Sending BUFFER_SIZES query...');
106
+ const bufferQuery = session.buildCommand(CMD.BUFFER_SIZES);
107
+ sendPacket(bufferQuery);
108
+ }, 6000);
109
+ }, 2000);
110
+ }
111
+
112
+ function handleSystemResponse(cmdName, parsed) {
113
+ const data = parsed.commandData;
114
+ log(`\n[${cmdName} Response] Received data`);
115
+
116
+ console.log(`\n========================================`);
117
+ console.log(`${cmdName} RESPONSE`);
118
+ console.log(`========================================`);
119
+ if (data && data.length > 0) {
120
+ console.log(`Data (${data.length} bytes):`);
121
+ console.log(` HEX: ${data.toString('hex').toUpperCase()}`);
122
+ console.log(` ASCII: ${Array.from(data).map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.').join('')}`);
123
+ console.log(` Bytes: [${Array.from(data).join(', ')}]`);
124
+ } else {
125
+ console.log(`No data returned`);
126
+ }
127
+ console.log(`========================================\n`);
128
+
129
+ // Send ACK
130
+ sendPacket(session.buildSimpleAck());
131
+ }
132
+
133
+ function handleGlobalStatusResponse(parsed) {
134
+ const data = parsed.commandData;
135
+ log(`\n[Global Status] Received global status data`);
136
+
137
+ console.log(`\n========================================`);
138
+ console.log(`GLOBAL STATUS RESPONSE`);
139
+ console.log(`========================================`);
140
+
141
+ if (data && data.length > 0) {
142
+ console.log(`Raw data (${data.length} bytes): ${data.toString('hex').toUpperCase()}`);
143
+
144
+ // Global status may contain partition and zone summary info
145
+ // Format depends on what the panel sends
146
+ // For now, display raw hex and let user interpret
147
+ console.log(`\nData breakdown:`);
148
+ for (let i = 0; i < data.length; i++) {
149
+ console.log(` Byte ${i}: 0x${data[i].toString(16).padStart(2, '0').toUpperCase()} (${data[i]})`);
150
+ }
151
+ } else {
152
+ console.log(`No data returned`);
153
+ }
154
+ console.log(`========================================\n`);
155
+
156
+ sendPacket(session.buildSimpleAck());
157
+ }
158
+
159
+ function handleZoneStatusResponse(parsed) {
160
+ const data = parsed.commandData;
161
+ log(`\n[Zone Status] Received zone status data`);
162
+
163
+ if (data && data.length >= 3) {
164
+ const zoneNum = data.readUInt16BE(0);
165
+ const statusByte = data[2];
166
+
167
+ // Parse using new parser
168
+ const status = parseZoneStatus(statusByte);
169
+ status.zoneNumber = zoneNum;
170
+
171
+ // Also update EventHandler for internal tracking
172
+ eventHandler.handleZoneStatus(zoneNum, statusByte);
173
+
174
+ console.log(`\n========================================`);
175
+ console.log(`ZONE STATUS for Zone ${zoneNum}`);
176
+ console.log(`========================================`);
177
+ console.log(`Status Byte: 0x${statusByte.toString(16).padStart(2, '0').toUpperCase()}`);
178
+ console.log(` Open: ${status.open ? 'YES' : 'NO'}`);
179
+ console.log(` Tamper: ${status.tamper ? 'YES' : 'NO'}`);
180
+ console.log(` Fault: ${status.fault ? 'YES' : 'NO'}`);
181
+ console.log(` Low Battery: ${status.lowBattery ? 'YES' : 'NO'}`);
182
+ console.log(` Delinquency: ${status.delinquency ? 'YES' : 'NO'}`);
183
+ console.log(` Alarm: ${status.alarm ? 'YES' : 'NO'}`);
184
+ console.log(` Alarm Memory: ${status.alarmInMemory ? 'YES' : 'NO'}`);
185
+ console.log(` Bypassed: ${status.bypassed ? 'YES' : 'NO'}`);
186
+ console.log(`\n${formatZoneStatus(status)}`);
187
+ console.log(`========================================\n`);
188
+ }
189
+
190
+ // Send ACK
191
+ sendPacket(session.buildSimpleAck());
192
+ }
193
+
194
+ function handlePartitionStatusResponse(parsed) {
195
+ const data = parsed.commandData;
196
+ log(`\n[Partition Status] Received partition status data`);
197
+
198
+ if (data && data.length >= 3) {
199
+ const partitionNum = data.readUInt16BE(0);
200
+ const statusBytes = data.slice(2);
201
+
202
+ // Parse using new parser
203
+ const status = parsePartitionStatus(statusBytes);
204
+ status.partitionNumber = partitionNum;
205
+
206
+ console.log(`\n========================================`);
207
+ console.log(`PARTITION STATUS for Partition ${partitionNum}`);
208
+ console.log(`========================================`);
209
+ console.log(`Status Bytes: ${statusBytes.toString('hex').toUpperCase()}`);
210
+ console.log(` Armed State: ${status.armedState}`);
211
+ console.log(` Ready: ${status.ready ? 'YES' : 'NO'}`);
212
+ console.log(` Alarm: ${status.alarm ? 'YES' : 'NO'}`);
213
+ console.log(` Trouble: ${status.trouble ? 'YES' : 'NO'}`);
214
+ console.log(` Bypass Active: ${status.bypass ? 'YES' : 'NO'}`);
215
+ console.log(` Busy: ${status.busy ? 'YES' : 'NO'}`);
216
+ console.log(` Alarm Memory: ${status.alarmMemory ? 'YES' : 'NO'}`);
217
+ console.log(` Audible Bell: ${status.audibleBell ? 'YES' : 'NO'}`);
218
+ console.log(` Buzzer: ${status.buzzer ? 'YES' : 'NO'}`);
219
+ console.log(`\n${formatPartitionStatus(status)}`);
220
+ console.log(`========================================\n`);
221
+ }
222
+
223
+ // Send ACK
224
+ sendPacket(session.buildSimpleAck());
225
+ }
226
+
227
+ function handleZoneAlarmNotification(parsed) {
228
+ const data = parsed.commandData;
229
+ log(`\n[Zone Alarm] Received zone alarm notification`);
230
+
231
+ if (data && data.length >= 3) {
232
+ const zoneNum = data.readUInt16BE(0);
233
+ const alarmType = data[2];
234
+
235
+ eventHandler.handleZoneAlarm(zoneNum, alarmType);
236
+
237
+ console.log(`\n========================================`);
238
+ console.log(`*** ZONE ALARM ***`);
239
+ console.log(`Zone: ${zoneNum}`);
240
+ console.log(`Alarm Type: ${alarmType}`);
241
+ console.log(`========================================\n`);
242
+ }
243
+
244
+ sendPacket(session.buildSimpleAck());
245
+ }
246
+
247
+ function handleLifestyleZoneNotification(parsed) {
248
+ const data = parsed.commandData;
249
+
250
+ if (data && data.length >= 2) {
251
+ const zoneNum = data[0]; // Zone number (single byte for 0x0210)
252
+ const status = data[1]; // 0=Closed, 1=Closed, 2=Open (based on SDK output)
253
+
254
+ const statusText = status === 0 ? 'CLOSED' : (status === 1 ? 'CLOSED' : 'OPEN');
255
+
256
+ console.log(`\n[Zone ${zoneNum}] ${statusText}`);
257
+ log(`[Lifestyle Zone] Zone ${zoneNum}: ${statusText} (status byte: ${status})`);
258
+ }
259
+
260
+ sendPacket(session.buildSimpleAck());
261
+ }
262
+
263
+ function handleTimeDateNotification(parsed) {
264
+ const data = parsed.commandData;
265
+
266
+ if (data && data.length >= 5) {
267
+ // Parse date/time from payload
268
+ log(`[Time/Date] Received time/date broadcast: ${data.toString('hex')}`);
269
+ }
270
+
271
+ sendPacket(session.buildSimpleAck());
272
+ }
273
+
274
+ function handlePartitionArmingNotification(parsed) {
275
+ const data = parsed.commandData;
276
+ log(`\n[Partition Arming] Received partition arming notification`);
277
+
278
+ if (data && data.length >= 3) {
279
+ const partitionNum = data.readUInt16BE(0);
280
+ const armedState = data[2];
281
+
282
+ const status = eventHandler.handlePartitionArming(partitionNum, armedState);
283
+
284
+ console.log(`\n========================================`);
285
+ console.log(`PARTITION ARMING NOTIFICATION`);
286
+ console.log(`Partition: ${partitionNum}`);
287
+ console.log(`State: ${status.armedStateName}`);
288
+ console.log(`Armed: ${status.isArmed ? 'YES' : 'NO'}`);
289
+ console.log(`In Delay: ${status.inDelay ? 'YES' : 'NO'}`);
290
+ console.log(`========================================\n`);
291
+ }
292
+
293
+ sendPacket(session.buildSimpleAck());
294
+ }
295
+
296
+ function handlePartitionReadyNotification(parsed) {
297
+ const data = parsed.commandData;
298
+ log(`\n[Partition Ready] Received partition ready notification`);
299
+
300
+ if (data && data.length >= 3) {
301
+ const partitionNum = data.readUInt16BE(0);
302
+ const isReady = data[2] !== 0;
303
+
304
+ eventHandler.handlePartitionReady(partitionNum, isReady);
305
+
306
+ console.log(`\n========================================`);
307
+ console.log(`PARTITION READY NOTIFICATION`);
308
+ console.log(`Partition: ${partitionNum}`);
309
+ console.log(`Ready: ${isReady ? 'YES' : 'NO'}`);
310
+ console.log(`========================================\n`);
311
+ }
312
+
313
+ sendPacket(session.buildSimpleAck());
314
+ }
315
+
316
+ function handlePartitionTroubleNotification(parsed) {
317
+ const data = parsed.commandData;
318
+ log(`\n[Partition Trouble] Received partition trouble notification`);
319
+
320
+ if (data && data.length >= 3) {
321
+ const partitionNum = data.readUInt16BE(0);
322
+ const troubleFlags = data[2];
323
+
324
+ eventHandler.handlePartitionTrouble(partitionNum, troubleFlags);
325
+
326
+ console.log(`\n========================================`);
327
+ console.log(`PARTITION TROUBLE NOTIFICATION`);
328
+ console.log(`Partition: ${partitionNum}`);
329
+ console.log(`Trouble Flags: 0x${troubleFlags.toString(16).padStart(2, '0')}`);
330
+ console.log(`========================================\n`);
331
+ }
332
+
333
+ sendPacket(session.buildSimpleAck());
334
+ }
335
+
336
+ function handleOpenSession(parsed) {
337
+ // OPEN_SESSION does NOT have an app sequence - the "appSequence" field is actually the device type
338
+ // Re-parse: [sender][receiver][cmd 2B][device_type][payload...]
339
+ // parsed.appSequence is actually the device type (first payload byte)
340
+ // parsed.commandData starts at the second payload byte
341
+
342
+ const deviceType = parsed.appSequence; // This is actually the device type, not app seq
343
+ const data = parsed.commandData;
344
+
345
+ if (deviceType !== undefined && data && data.length >= 13) {
346
+ // Reconstruct full payload: [deviceType] + [rest of data]
347
+ const fullPayload = Buffer.concat([Buffer.from([deviceType]), data]);
348
+
349
+ const deviceId = fullPayload.slice(1, 3).toString('hex');
350
+ const firmware = fullPayload.slice(3, 5).toString('hex');
351
+ const protocol = fullPayload.slice(5, 7).toString('hex');
352
+ const txBuffer = fullPayload.readUInt16BE(7);
353
+ const rxBuffer = fullPayload.readUInt16BE(9);
354
+ const encType = fullPayload[13];
355
+
356
+ log(`[Session] OPEN_SESSION received:`);
357
+ log(` Device Type: ${deviceType}`);
358
+ log(` Device ID: ${deviceId}`);
359
+ log(` Firmware: ${firmware}`);
360
+ log(` Protocol: ${protocol}`);
361
+ log(` TX Buffer: ${txBuffer}`);
362
+ log(` RX Buffer: ${rxBuffer}`);
363
+ log(` Encryption Type: ${encType}`);
364
+
365
+ // Reset session if panel is starting a new handshake
366
+ if (handshakeState !== 'WAITING' && handshakeState !== 'SENT_CMD_RESPONSE_1') {
367
+ log(`[Session] Panel restarting handshake (was in state ${handshakeState}), resetting session`);
368
+ session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
369
+ testCommandSent = false;
370
+ }
371
+
372
+ session.remoteOpenSession = fullPayload;
373
+ log(`[Handshake] Panel advertises encryption Type ${encType}`);
374
+
375
+ // Respond with COMMAND_RESPONSE success
376
+ // SDK shows: COMMAND_RESPONSE echoes the first byte (device type) from OPEN_SESSION
377
+ log(`[Handshake] Sending COMMAND_RESPONSE success (echoing device type ${deviceType})`);
378
+ const response = session.buildCommandResponseWithAppSeq(deviceType, 0x00);
379
+ sendPacket(response);
380
+ handshakeState = 'SENT_CMD_RESPONSE_1';
381
+ }
382
+ }
383
+
384
+ function handleRequestAccess(parsed) {
385
+ const data = parsed.commandData;
386
+ log(`[Handshake] Got panel's REQUEST_ACCESS`);
387
+
388
+ // If already established, just ACK and ignore the retry
389
+ if (handshakeState === 'ESTABLISHED') {
390
+ log(`[Handshake] Session already established, ACKing panel's REQUEST_ACCESS retry`);
391
+ sendPacket(session.buildSimpleAck());
392
+ return;
393
+ }
394
+
395
+ if (data && data.length > 0) {
396
+ const initializerLength = data[0];
397
+ const initializer = data.slice(1);
398
+
399
+ log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
400
+ log(`[Handshake] Panel initializer (${initializer.length} bytes): ${initializer.toString('hex')}`);
401
+
402
+ if (initializer.length === 48) {
403
+ // Type 1 encryption
404
+ detectedEncryptionType = 1;
405
+ log(`[Handshake] Encryption Type 1 (based on initializer length)`);
406
+ log(`[Handshake] Type 1: Parsing 48-byte initializer from panel`);
407
+ log(`[Handshake] Using access code: ${ACCESS_CODE}, integration ID: ${INTEGRATION_ID}`);
408
+
409
+ try {
410
+ const sendKey = parseType1Initializer(ACCESS_CODE, initializer, INTEGRATION_ID);
411
+ log(`[Handshake] Type 1 SEND key derived: ${sendKey.toString('hex')}`);
412
+
413
+ // Store the SEND key for later (after handshake completes)
414
+ // Based on SDK trace: ALL handshake frames are PLAINTEXT
415
+ session.derivedSendKey = sendKey;
416
+ log(`[Handshake] SEND key stored (will enable after handshake): ${sendKey.toString('hex')}`);
417
+
418
+ // Send PLAINTEXT COMMAND_RESPONSE (echoing the app sequence from REQUEST_ACCESS)
419
+ // SDK trace shows: 08 03 02 05 02 66 00 9B A9 (plaintext)
420
+ log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
421
+ const appSeq = parsed.appSequence; // Echo the app sequence from the request
422
+ const response = session.buildCommandResponseWithAppSeq(appSeq, 0x00);
423
+ sendPacket(response);
424
+
425
+ // Wait for panel to ACK our COMMAND_RESPONSE before sending our REQUEST_ACCESS
426
+ // Sending back-to-back was causing the panel to reset
427
+ handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
428
+ log(`[Handshake] Waiting for ACK before sending our REQUEST_ACCESS`);
429
+ } catch (err) {
430
+ log(`[Handshake] Error parsing Type 1 initializer: ${err.message}`);
431
+ }
432
+ } else if (initializer.length === 16) {
433
+ // Type 2 encryption
434
+ detectedEncryptionType = 2;
435
+ log(`[Handshake] Encryption Type 2 (based on initializer length)`);
436
+ log(`[Handshake] Type 2: Parsing 16-byte initializer from panel`);
437
+
438
+ try {
439
+ // Expand access code to 32 hex if it's 8 digits
440
+ let accessCode32 = ACCESS_CODE;
441
+ if (accessCode32.length === 8) {
442
+ accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
443
+ }
444
+
445
+ const sendKey = type2InitializerTransform(accessCode32, initializer);
446
+ log(`[Handshake] Type 2 SEND key derived: ${sendKey.toString('hex')}`);
447
+
448
+ session.derivedSendKey = sendKey;
449
+ log(`[Handshake] SEND key stored (will enable after handshake): ${sendKey.toString('hex')}`);
450
+
451
+ // Send PLAINTEXT COMMAND_RESPONSE
452
+ log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
453
+ const appSeq = parsed.appSequence;
454
+ const response = session.buildCommandResponseWithAppSeq(appSeq, 0x00);
455
+ sendPacket(response);
456
+
457
+ handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
458
+ log(`[Handshake] Waiting for ACK before sending our REQUEST_ACCESS`);
459
+ } catch (err) {
460
+ log(`[Handshake] Error parsing Type 2 initializer: ${err.message}`);
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ function handleSimpleAck(parsed) {
467
+ const ackSeq = parsed.receiverSequence;
468
+ log(`[Handshake] Got ACK ${ackSeq}, state=${handshakeState}`);
469
+
470
+ if (handshakeState === 'SENT_CMD_RESPONSE_1') {
471
+ // Send our OPEN_SESSION (PLAINTEXT)
472
+ log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
473
+ // SDK trace shows OPEN_SESSION payload does NOT have AppSeq prefix for server->panel direction
474
+ // SDK packet: 15 02 00 06 0A 01 80 00 00 02 01 02 41 02 00 02 00 00 01 01 A7 4B
475
+ // Payload starts with DeviceType=0x01, NOT ApplicationSequenceNumber
476
+ const openSessionPayload = Buffer.from([
477
+ 0x01, // Device Type (0x01 = server, vs 0x02 for panel)
478
+ 0x80, 0x00, // Device ID (our ID)
479
+ 0x00, 0x02, // Firmware version (0.2)
480
+ 0x01, 0x02, // Protocol version
481
+ 0x41, 0x02, // TX Buffer (little-endian 0x0241 = 577)
482
+ 0x00, 0x02, // RX Buffer (little-endian 0x0200 = 512)
483
+ 0x00, 0x00, // Future/reserved
484
+ 0x01, // Encryption Type (1 = Type 1)
485
+ 0x01, // Extra byte (matches SDK exactly)
486
+ ]);
487
+ const packet = session.buildOpenSessionResponse(openSessionPayload);
488
+ sendPacket(packet);
489
+ handshakeState = 'SENT_OPEN_SESSION';
490
+ } else if (handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
491
+ // Panel ACKed our COMMAND_RESPONSE to their REQUEST_ACCESS
492
+ // Now send our REQUEST_ACCESS
493
+ log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
494
+ // Enable encryption with the SEND key derived from panel's initializer
495
+ session.sendAesActive = true;
496
+ session.sendAesKey = session.derivedSendKey;
497
+ log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key: ${session.sendAesKey?.toString('hex')}`);
498
+
499
+ // Build REQUEST_ACCESS based on detected encryption type
500
+ let reqAccessPacket;
501
+ if (detectedEncryptionType === 2) {
502
+ log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
503
+ reqAccessPacket = session.buildRequestAccessType2();
504
+ } else {
505
+ log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
506
+ reqAccessPacket = session.buildRequestAccessType1();
507
+ }
508
+
509
+ log(`[Handshake] Pending receive key stored: ${session.pendingReceiveKey?.toString('hex')}`);
510
+ sendPacket(reqAccessPacket);
511
+
512
+ // Enable receive encryption immediately - panel will respond with encrypted COMMAND_RESPONSE
513
+ session.receiveAesActive = true;
514
+ session.receiveAesKey = session.pendingReceiveKey;
515
+ log(`[Handshake] Enabled receive encryption with RECV key: ${session.receiveAesKey?.toString('hex')}`);
516
+
517
+ handshakeState = 'SENT_REQUEST_ACCESS';
518
+ } else if (handshakeState === 'SENT_REQUEST_ACCESS') {
519
+ // Panel ACKed our REQUEST_ACCESS - session is established!
520
+ handshakeState = 'ESTABLISHED';
521
+ log(`[Handshake] *** SESSION ESTABLISHED ***`);
522
+ log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
523
+ log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
524
+
525
+ // NOW enable encryption for post-handshake communication
526
+ if (session.derivedSendKey) {
527
+ session.enableSendAes(session.derivedSendKey);
528
+ log(`[Handshake] SEND AES now enabled: ${session.derivedSendKey.toString('hex')}`);
529
+ }
530
+ if (session.pendingReceiveKey) {
531
+ session.enableReceiveAes(session.pendingReceiveKey);
532
+ log(`[Handshake] RECV AES now enabled: ${session.pendingReceiveKey.toString('hex')}`);
533
+ }
534
+
535
+ console.log(`\n[Ready] Session established!`);
536
+ console.log(` SEND key: ${session.derivedSendKey?.toString('hex')}`);
537
+ console.log(` RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
538
+ console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
539
+ }
540
+ }
541
+
542
+ function handleCommandResponse(parsed) {
543
+ const data = parsed.commandData;
544
+ // COMMAND_RESPONSE payload format: [echoed_byte, response_code]
545
+ // Note: parseHeader treats byte 4 as appSequence, which for COMMAND_RESPONSE is actually the echoed byte
546
+ const appSeqAsEcho = parsed.appSequence; // This is actually the echoed byte from our request
547
+ const echoedByte = data && data.length >= 1 ? data[0] : (appSeqAsEcho !== undefined ? appSeqAsEcho : 0xFF);
548
+ const responseCode = data && data.length >= 1 ? data[0] : 0xFF;
549
+ log(`[Handshake] Got COMMAND_RESPONSE (echoed app_seq: ${appSeqAsEcho}, response_code: ${responseCode}), sending ACK`);
550
+
551
+ const ack = session.buildSimpleAck();
552
+ sendPacket(ack);
553
+
554
+ if (handshakeState === 'SENT_OPEN_SESSION') {
555
+ // Just waiting for panel's REQUEST_ACCESS now
556
+ handshakeState = 'WAITING_REQUEST_ACCESS';
557
+ } else if (handshakeState === 'SENT_REQUEST_ACCESS') {
558
+ // Session established!
559
+ handshakeState = 'ESTABLISHED';
560
+ log(`[Handshake] *** SESSION ESTABLISHED ***`);
561
+ log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
562
+ log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
563
+
564
+ // NOW enable send encryption for post-handshake commands
565
+ if (session.derivedSendKey) {
566
+ session.enableSendAes(session.derivedSendKey);
567
+ log(`[Handshake] Send AES ENABLED with key: ${session.derivedSendKey.toString('hex')}`);
568
+ }
569
+
570
+ // Enable receive AES decryption in case panel encrypts responses
571
+ if (session.pendingReceiveKey) {
572
+ session.enableReceiveAes(session.pendingReceiveKey);
573
+ log(`[Handshake] Receive AES ENABLED with key: ${session.pendingReceiveKey.toString('hex')}`);
574
+ }
575
+
576
+ console.log(`\n[Ready] Session established (Type ${detectedEncryptionType || 1} encryption)`);
577
+ console.log(` SEND key: ${session.derivedSendKey?.toString('hex')}`);
578
+ console.log(` RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
579
+ console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
580
+
581
+ // Wait for session to stabilize, then send test zone query
582
+ // setTimeout(() => {
583
+ // sendTestZoneQuery();
584
+ // }, 3000);
585
+ }
586
+ }
587
+
588
+ function handleCommandError(parsed) {
589
+ const errorData = parsed.commandData;
590
+ log(`[Handshake] Received COMMAND_ERROR response`);
591
+ if (errorData) {
592
+ log(`[Handshake] Error data: ${errorData.toString('hex')}`);
593
+ }
594
+
595
+ // Send ACK anyway
596
+ const ack = session.buildSimpleAck();
597
+ sendPacket(ack);
598
+
599
+ if (handshakeState === 'SENT_REQUEST_ACCESS') {
600
+ // Session established despite error (we have keys)
601
+ handshakeState = 'ESTABLISHED';
602
+ log(`[Handshake] *** SESSION ESTABLISHED (despite error response) ***`);
603
+ log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
604
+ log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
605
+ console.log(`\n[Ready] Session established! (frames remain plaintext per SDK analysis)`);
606
+ console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
607
+
608
+ // Wait for session to stabilize, then send test zone query
609
+ // setTimeout(() => {
610
+ // sendTestZoneQuery();
611
+ // }, 3000);
612
+ }
613
+ }
614
+
615
+ server.on('message', (msg, rinfo) => {
616
+ log(`\n[UDP] RX from ${rinfo.address}:${rinfo.port} (${msg.length} bytes)`);
617
+ hexDump(msg);
618
+
619
+ // Track panel address
620
+ if (!panelAddress || panelAddress !== rinfo.address) {
621
+ panelAddress = rinfo.address;
622
+ panelPort = rinfo.port;
623
+ log(`[Session] Creating new session for ${panelAddress}`);
624
+ session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
625
+ handshakeState = 'WAITING';
626
+ testCommandSent = false;
627
+ }
628
+
629
+ try {
630
+ const parsed = session.parsePacket(msg);
631
+
632
+ log(`[Packet] Integration ID: ${parsed.integrationId}`);
633
+ log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
634
+
635
+ const cmdName = CMD_NAMES[parsed.command] || `0x${parsed.command?.toString(16)}`;
636
+ log(`[Packet] Command: ${cmdName}`);
637
+
638
+ if (parsed.appSequence !== undefined) {
639
+ log(`[Packet] App Seq: ${parsed.appSequence}`);
640
+ }
641
+ if (parsed.commandData && parsed.commandData.length > 0) {
642
+ log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{2}/g).join(' ')}`);
643
+ }
644
+
645
+ // Handle by command
646
+ // Note: ACK packets have no command field (null), treat as SIMPLE_ACK
647
+ const cmd = parsed.command;
648
+
649
+ if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
650
+ handleSimpleAck(parsed);
651
+ return;
652
+ }
653
+
654
+ switch (cmd) {
655
+ case CMD.OPEN_SESSION:
656
+ handleOpenSession(parsed);
657
+ break;
658
+ case CMD.REQUEST_ACCESS:
659
+ handleRequestAccess(parsed);
660
+ break;
661
+ case CMD.COMMAND_RESPONSE:
662
+ handleCommandResponse(parsed);
663
+ break;
664
+ case CMD.COMMAND_ERROR:
665
+ handleCommandError(parsed);
666
+ break;
667
+ case CMD.POLL:
668
+ log(`[Session] Got POLL, sending ACK`);
669
+ sendPacket(session.buildSimpleAck());
670
+ break;
671
+ case CMD.ZONE_STATUS:
672
+ handleZoneStatusResponse(parsed);
673
+ break;
674
+ case CMD.PARTITION_STATUS:
675
+ handlePartitionStatusResponse(parsed);
676
+ break;
677
+ case CMD.GLOBAL_STATUS:
678
+ handleGlobalStatusResponse(parsed);
679
+ break;
680
+ case CMD.SYSTEM_CAPABILITIES:
681
+ handleSystemResponse('SYSTEM_CAPABILITIES', parsed);
682
+ break;
683
+ case CMD.SOFTWARE_VERSION:
684
+ handleSystemResponse('SOFTWARE_VERSION', parsed);
685
+ break;
686
+ case CMD.PANEL_STATUS:
687
+ handleSystemResponse('PANEL_STATUS', parsed);
688
+ break;
689
+ case CMD.BUFFER_SIZES:
690
+ handleSystemResponse('BUFFER_SIZES', parsed);
691
+ break;
692
+ // Notification events from panel
693
+ case CMD.NOTIFICATION_ARMING:
694
+ handlePartitionArmingNotification(parsed);
695
+ break;
696
+ case CMD.NOTIFICATION_PARTITION_READY:
697
+ handlePartitionReadyNotification(parsed);
698
+ break;
699
+ case CMD.NOTIFICATION_PARTITION_TROUBLE:
700
+ handlePartitionTroubleNotification(parsed);
701
+ break;
702
+ case CMD.ZONE_ALARM_STATUS:
703
+ handleZoneAlarmNotification(parsed);
704
+ break;
705
+ case CMD.LIFESTYLE_ZONE_STATUS:
706
+ handleLifestyleZoneNotification(parsed);
707
+ break;
708
+ case CMD.TIME_DATE_BROADCAST:
709
+ handleTimeDateNotification(parsed);
710
+ break;
711
+ case CMD.GENERAL_NOTIFICATION:
712
+ case CMD.DATA_UPDATE_NOTIFICATION:
713
+ log(`[Notification] Received ${CMD_NAMES[cmd] || cmd} notification`);
714
+ if (parsed.commandData) {
715
+ log(`[Notification] Data: ${parsed.commandData.toString('hex')}`);
716
+ }
717
+ sendPacket(session.buildSimpleAck());
718
+ break;
719
+ default:
720
+ if (handshakeState === 'ESTABLISHED') {
721
+ log(`[Session] Handling command ${CMD_NAMES[cmd] || '0x' + cmd.toString(16)} in established session`);
722
+ // Send ACK for unhandled commands
723
+ sendPacket(session.buildSimpleAck());
724
+ }
725
+ break;
726
+ }
727
+ } catch (err) {
728
+ log(`[Error] ${err.message}`);
729
+
730
+ // If we get a parse error, the panel might be sending encrypted data from a stale session
731
+ // Reset our state and wait for panel to timeout and restart with plaintext OPEN_SESSION
732
+ if (handshakeState !== 'WAITING') {
733
+ log(`[Recovery] Parse error during handshake state '${handshakeState}', resetting to wait for panel restart`);
734
+ handshakeState = 'WAITING';
735
+ session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
736
+ log(`[Recovery] Session reset. Waiting for panel to timeout and send plaintext OPEN_SESSION...`);
737
+ } else {
738
+ log(`[Recovery] Ignoring unparseable packet, waiting for plaintext OPEN_SESSION...`);
739
+ }
740
+ }
741
+ });
742
+
743
+ server.on('listening', () => {
744
+ const addr = server.address();
745
+ log(`[UDP] Server listening on ${addr.address}:${addr.port}`);
746
+ });
747
+
748
+ server.on('error', (err) => {
749
+ log(`[UDP] Server error: ${err.message}`);
750
+ server.close();
751
+ });
752
+
753
+ // Interactive command handling
754
+ const rl = readline.createInterface({
755
+ input: process.stdin,
756
+ output: process.stdout
757
+ });
758
+
759
+ rl.on('line', (line) => {
760
+ const [cmd, ...args] = line.trim().split(/\s+/);
761
+
762
+ if (!session || handshakeState !== 'ESTABLISHED') {
763
+ console.log('[Error] Session not established');
764
+ return;
765
+ }
766
+
767
+ switch (cmd) {
768
+ case 'auth': {
769
+ const code = args[0] || MASTER_CODE;
770
+ const partition = parseInt(args[1] || '1', 10);
771
+ log(`[Command] Authenticating with code ${code} on partition ${partition}`);
772
+ const packet = session.buildAccessLevelEnter(code, partition, 0, 'bcd');
773
+ sendPacket(packet);
774
+ break;
775
+ }
776
+
777
+ // ============ Arm/Disarm Commands ============
778
+ case 'arm': {
779
+ const partition = parseInt(args[0] || '1', 10);
780
+ const mode = parseInt(args[1] || '1', 10); // 0=stay, 1=away, 2=no-entry, 3=force
781
+ const code = args[2] || MASTER_CODE;
782
+
783
+ const modeNames = ['STAY', 'AWAY', 'NO_ENTRY', 'FORCE'];
784
+ log(`[Command] Arming partition ${partition} in ${modeNames[mode] || mode} mode`);
785
+ const packet = session.buildPartitionArm(partition, mode, code);
786
+ sendPacket(packet);
787
+ break;
788
+ }
789
+ case 'disarm': {
790
+ const partition = parseInt(args[0] || '1', 10);
791
+ const code = args[1] || MASTER_CODE;
792
+
793
+ log(`[Command] Disarming partition ${partition}`);
794
+ const packet = session.buildPartitionDisarm(partition, code);
795
+ sendPacket(packet);
796
+ break;
797
+ }
798
+ case 'stay': {
799
+ // Shortcut for arm stay
800
+ const partition = parseInt(args[0] || '1', 10);
801
+ const code = args[1] || MASTER_CODE;
802
+
803
+ log(`[Command] Arming partition ${partition} in STAY mode`);
804
+ const packet = session.buildPartitionArm(partition, 0, code);
805
+ sendPacket(packet);
806
+ break;
807
+ }
808
+ case 'away': {
809
+ // Shortcut for arm away
810
+ const partition = parseInt(args[0] || '1', 10);
811
+ const code = args[1] || MASTER_CODE;
812
+
813
+ log(`[Command] Arming partition ${partition} in AWAY mode`);
814
+ const packet = session.buildPartitionArm(partition, 1, code);
815
+ sendPacket(packet);
816
+ break;
817
+ }
818
+
819
+ // ============ Status Display Commands ============
820
+ case 'zones': {
821
+ // Show all zones with status from EventHandler
822
+ const zones = eventHandler.getAllZones();
823
+ console.log(`\n========================================`);
824
+ console.log(`ALL ZONES (${zones.length} with status)`);
825
+ console.log(`========================================`);
826
+ if (zones.length === 0) {
827
+ console.log(' No zone status received yet');
828
+ console.log(' (Open/close a zone to trigger notification)');
829
+ } else {
830
+ zones.forEach(z => {
831
+ const flags = [];
832
+ if (z.open) flags.push('OPEN');
833
+ if (z.bypassed) flags.push('BYPASSED');
834
+ if (z.alarm) flags.push('ALARM');
835
+ if (z.trouble) flags.push('TROUBLE');
836
+ if (z.tamper) flags.push('TAMPER');
837
+ console.log(` Zone ${z.zoneNumber}: ${flags.length > 0 ? flags.join(', ') : 'OK/CLOSED'}`);
838
+ });
839
+ }
840
+ console.log(`========================================\n`);
841
+ break;
842
+ }
843
+ case 'partitions': {
844
+ // Show all partitions with status from EventHandler
845
+ const partitions = eventHandler.getAllPartitions();
846
+ console.log(`\n========================================`);
847
+ console.log(`ALL PARTITIONS (${partitions.length} with status)`);
848
+ console.log(`========================================`);
849
+ if (partitions.length === 0) {
850
+ console.log(' No partition status received yet');
851
+ console.log(' (Arm/disarm to trigger notification)');
852
+ } else {
853
+ partitions.forEach(p => {
854
+ console.log(` Partition ${p.partitionNumber}: ${p.armedStateName || 'UNKNOWN'} ${p.ready ? '[READY]' : '[NOT READY]'}`);
855
+ });
856
+ }
857
+ console.log(`========================================\n`);
858
+ break;
859
+ }
860
+ case 'status': {
861
+ // Show complete system status
862
+ eventHandler.printStatus();
863
+ break;
864
+ }
865
+
866
+ // ============ Query Commands ============
867
+ case 'p': {
868
+ const partition = parseInt(args[0] || '1', 10);
869
+ log(`[Command] Querying partition ${partition} status`);
870
+ const packet = session.buildPartitionStatusRequest(partition);
871
+ sendPacket(packet);
872
+ break;
873
+ }
874
+ case 'z': {
875
+ const zone = parseInt(args[0] || '0', 10);
876
+ log(`[Command] Querying zone ${zone} status`);
877
+ const packet = session.buildZoneStatusRequest(zone);
878
+ sendPacket(packet);
879
+ break;
880
+ }
881
+ case 'g': {
882
+ log(`[Command] Querying global status`);
883
+ const packet = session.buildGlobalStatusRequest();
884
+ sendPacket(packet);
885
+ break;
886
+ }
887
+ case 't': {
888
+ log(`[Command] Querying trouble status`);
889
+ const packet = session.buildTroubleStatusRequest();
890
+ sendPacket(packet);
891
+ break;
892
+ }
893
+ case 'b': {
894
+ const zone = parseInt(args[0] || '0', 10);
895
+ log(`[Command] Querying zone ${zone} bypass status`);
896
+ const packet = session.buildZoneBypassStatusRequest(zone);
897
+ sendPacket(packet);
898
+ break;
899
+ }
900
+ case 'cap': {
901
+ log(`[Command] Requesting system capabilities`);
902
+ const packet = session.buildCommand(CMD.SYSTEM_CAPABILITIES);
903
+ sendPacket(packet);
904
+ break;
905
+ }
906
+ case 'poll': {
907
+ log(`[Command] Sending POLL/keepalive`);
908
+ const packet = session.buildPoll();
909
+ sendPacket(packet);
910
+ break;
911
+ }
912
+ case 'exit':
913
+ case 'quit': {
914
+ console.log('[Shutdown] Closing session...');
915
+ if (session && handshakeState === 'ESTABLISHED') {
916
+ try {
917
+ const endSessionPacket = session.buildEndSession();
918
+ sendPacket(endSessionPacket);
919
+ console.log('[Shutdown] END_SESSION sent to panel');
920
+ } catch (err) {
921
+ console.log(`[Shutdown] Error: ${err.message}`);
922
+ }
923
+ }
924
+ setTimeout(() => {
925
+ console.log('[Shutdown] Goodbye!');
926
+ process.exit(0);
927
+ }, 100);
928
+ break;
929
+ }
930
+ case 'help':
931
+ console.log(`
932
+ Interactive Commands:
933
+
934
+ ARM/DISARM:
935
+ arm <part> <mode> [code] - Arm partition (mode: 0=stay, 1=away, 2=no-entry, 3=force)
936
+ disarm <part> [code] - Disarm partition
937
+ stay [part] [code] - Shortcut: Arm partition in STAY mode
938
+ away [part] [code] - Shortcut: Arm partition in AWAY mode
939
+
940
+ STATUS DISPLAY:
941
+ zones - Show all zones with current status
942
+ partitions - Show all partitions with current status
943
+ status - Show complete system status summary
944
+
945
+ QUERY PANEL:
946
+ auth [code] - Authenticate with access code (default=${MASTER_CODE})
947
+ p [n] - Query partition n status (default=1)
948
+ z [n] - Query zone n status (default=0=all)
949
+ g - Query global status
950
+ t - Query trouble status
951
+ b [n] - Query zone bypass status
952
+ cap - Request system capabilities
953
+ poll - Send poll/keepalive
954
+
955
+ OTHER:
956
+ help - Show this help
957
+
958
+ Examples:
959
+ arm 1 0 5555 - Arm partition 1 in STAY mode with code 5555
960
+ arm 1 1 - Arm partition 1 in AWAY mode with default code
961
+ disarm 1 - Disarm partition 1 with default code
962
+ stay - Arm partition 1 in STAY mode
963
+ away - Arm partition 1 in AWAY mode
964
+ zones - Show current zone status
965
+ status - Show system summary
966
+ `);
967
+ break;
968
+ default:
969
+ console.log(`[Error] Unknown command: ${cmd}. Type 'help' for available commands.`);
970
+ }
971
+ });
972
+
973
+ // Print banner
974
+ console.log(`
975
+ ============================================================
976
+ DSC TL280R ITV2 Protocol Server (Enhanced)
977
+ ============================================================
978
+
979
+ Configuration:
980
+ Integration ID: ${INTEGRATION_ID}
981
+ Access Code: ${ACCESS_CODE}
982
+ Master Code: ${MASTER_CODE}
983
+ UDP Port: ${UDP_PORT}
984
+
985
+ Set environment variables to override:
986
+ INTEGRATION_ID=${INTEGRATION_ID} ACCESS_CODE=12345678 MASTER_CODE=${MASTER_CODE} npm run server
987
+
988
+ Interactive Commands (type when session established):
989
+
990
+ ARM/DISARM:
991
+ arm <part> <mode> [code] - Arm partition (mode: 0=stay, 1=away)
992
+ disarm <part> [code] - Disarm partition
993
+ stay [part] [code] - Arm in STAY mode
994
+ away [part] [code] - Arm in AWAY mode
995
+
996
+ STATUS:
997
+ zones - Show all zones with status
998
+ partitions - Show all partitions with status
999
+ status - Show system status summary
1000
+
1001
+ QUERY:
1002
+ z [n], p [n], g, t, b [n] - Query zone/partition/global/trouble/bypass
1003
+ auth [code], cap, poll - Authenticate/capabilities/keepalive
1004
+
1005
+ OTHER:
1006
+ exit, quit - Close session and exit gracefully
1007
+
1008
+ Type 'help' for full command list.
1009
+
1010
+ Waiting for TL280R to connect...
1011
+ `);
1012
+
1013
+ server.bind(UDP_PORT, '0.0.0.0');
1014
+
1015
+ // Graceful shutdown handler
1016
+ process.on('SIGINT', () => {
1017
+ console.log('\n\n[Shutdown] Closing session gracefully...');
1018
+
1019
+ if (session && handshakeState === 'ESTABLISHED') {
1020
+ try {
1021
+ const endSessionPacket = session.buildEndSession();
1022
+ sendPacket(endSessionPacket);
1023
+ console.log('[Shutdown] END_SESSION sent to panel');
1024
+ } catch (err) {
1025
+ console.log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
1026
+ }
1027
+ }
1028
+
1029
+ setTimeout(() => {
1030
+ console.log('[Shutdown] Goodbye!');
1031
+ process.exit(0);
1032
+ }, 100);
1033
+ });