dsc-itv2-client 1.0.30 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ITV2Client.js CHANGED
@@ -1,985 +1,1408 @@
1
- /**
2
- * DSC ITV2 Protocol Client
3
- * Event-driven client for DSC alarm panels using the ITV2 protocol
4
- */
5
-
6
- import {EventEmitter} from 'events';
7
- import dgram from 'dgram';
8
- import net from 'net';
9
- import {ITv2Session, CMD, CMD_NAMES} from './itv2-session.js';
10
- import {parseType1Initializer, type2InitializerTransform} from './itv2-crypto.js';
11
- import {EventHandler} from './event-handler.js';
12
- import {parseCommandError} from './response-parsers.js';
13
-
14
- /**
15
- * ITV2Client - Main library class
16
- * Handles DSC panel communication and emits events for status updates
17
- */
18
- export class ITV2Client extends EventEmitter {
19
- constructor(options = {}) {
20
- super();
21
-
22
- // Configuration
23
- this.integrationId = options.integrationId;
24
- this.accessCode = options.accessCode;
25
- this.masterCode = options.masterCode || '5555';
26
- this.port = options.port || 3073; // UDP polling port
27
- this.tcpPort = options.tcpPort || 3072; // TCP notification port
28
- this.logLevel = options.logLevel || (options.debug === true ? 'verbose' : 'minimal'); // 'silent', 'minimal', 'verbose'
29
- this.encryptionType = options.encryptionType || null; // null = auto-detect, 1 = Type 1, 2 = Type 2
30
-
31
- // Internal state
32
- this.server = null; // UDP server
33
- this.tcpServer = null; // TCP server
34
- this.tcpClients = new Map(); // Active TCP connections
35
- this.session = null;
36
- this.eventHandler = new EventHandler();
37
- this.handshakeState = 'WAITING';
38
- this.panelAddress = null;
39
- this.panelPort = null;
40
- this.detectedEncryptionType = null; // Set during handshake
41
- this._lastTransport = 'udp'; // Track last packet source
42
- this._lastTcpSocket = null; // Track TCP socket for responses
43
-
44
- // Bind methods
45
- this._handlePacket = this._handlePacket.bind(this);
46
- this._handleError = this._handleError.bind(this);
47
- this._boundShutdown = null; // Track signal handler for cleanup
48
- }
49
-
50
- /**
51
- * Start the client and listen for panel connection
52
- */
53
- async start() {
54
- return new Promise((resolve, reject) => {
55
- this.server = dgram.createSocket('udp4');
56
-
57
- this.server.on('error', (err) => {
58
- this._log(`[Error] UDP server error: ${err.message}`);
59
- this.emit('error', err);
60
- reject(err);
61
- });
62
-
63
- this.server.on('message', (msg, rinfo) => {
64
- this.panelAddress = rinfo.address;
65
- this.panelPort = rinfo.port;
66
- this._handlePacket(msg);
67
- });
68
-
69
- this.server.on('listening', () => {
70
- const address = this.server.address();
71
- this._log(`[UDP] Server listening on ${address.address}:${address.port}`);
72
- this.emit('listening', address);
73
- resolve();
74
- });
75
-
76
- this.server.bind(this.port, '0.0.0.0');
77
-
78
- // Start TCP server for notification port
79
- this._startTcpServer();
80
-
81
- // Register signal handlers (only once)
82
- if (!this._boundShutdown) {
83
- this._boundShutdown = async () => {
84
- await this.stop();
85
- process.exit(0);
86
- };
87
- process.on('SIGINT', this._boundShutdown);
88
- process.on('SIGTERM', this._boundShutdown);
89
- }
90
- });
91
- }
92
-
93
- /**
94
- * Start TCP server for notification port (3072)
95
- */
96
- _startTcpServer() {
97
- this.tcpServer = net.createServer((socket) => {
98
- const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
99
- this._logMinimal(`[TCP] Client connected: ${clientId}`);
100
- this.tcpClients.set(clientId, socket);
101
-
102
- // Store panel address from TCP connection too
103
- if (!this.panelAddress) {
104
- this.panelAddress = socket.remoteAddress;
105
- }
106
-
107
- let buffer = Buffer.alloc(0);
108
-
109
- socket.on('data', (data) => {
110
- this._logMinimal(`[TCP] Received ${data.length} bytes from ${clientId}`);
111
- this._logMinimal(`[TCP] Raw data: ${data.toString('hex')}`);
112
-
113
- // Accumulate data in buffer
114
- buffer = Buffer.concat([buffer, data]);
115
-
116
- // Try to parse complete packets (look for 0x7E...0x7F framing)
117
- this._processTcpBuffer(buffer, socket, (remaining) => {
118
- buffer = remaining;
119
- });
120
- });
121
-
122
- socket.on('close', () => {
123
- this._logMinimal(`[TCP] Client disconnected: ${clientId}`);
124
- this.tcpClients.delete(clientId);
125
- });
126
-
127
- socket.on('error', (err) => {
128
- this._log(`[TCP] Socket error for ${clientId}: ${err.message}`);
129
- this.tcpClients.delete(clientId);
130
- });
131
- });
132
-
133
- this.tcpServer.on('error', (err) => {
134
- this._log(`[TCP] Server error: ${err.message}`);
135
- this.emit('error', err);
136
- });
137
-
138
- this.tcpServer.listen(this.tcpPort, '0.0.0.0', () => {
139
- this._logMinimal(`[TCP] Server listening on port ${this.tcpPort}`);
140
- this.emit('tcp:listening', { port: this.tcpPort });
141
- });
142
- }
143
-
144
- /**
145
- * Process TCP buffer looking for complete ITV2 packets
146
- */
147
- _processTcpBuffer(buffer, socket, callback) {
148
- // Look for Integration ID prefix followed by 0x7E
149
- const idLength = 12; // Integration ID is 12 ASCII chars
150
-
151
- while (buffer.length > idLength + 2) {
152
- // Find start marker (0x7E) after integration ID
153
- const startIdx = buffer.indexOf(0x7E, idLength - 1);
154
- if (startIdx === -1) {
155
- // No start marker found, keep last 12 bytes in case ID is split
156
- callback(buffer.slice(Math.max(0, buffer.length - idLength)));
157
- return;
158
- }
159
-
160
- // Find end marker (0x7F)
161
- const endIdx = buffer.indexOf(0x7F, startIdx + 1);
162
- if (endIdx === -1) {
163
- // No end marker yet, wait for more data
164
- callback(buffer);
165
- return;
166
- }
167
-
168
- // Extract complete packet (including ID prefix)
169
- const packetStart = Math.max(0, startIdx - idLength);
170
- const packet = buffer.slice(packetStart, endIdx + 1);
171
-
172
- this._logMinimal(`[TCP] Complete packet (${packet.length} bytes): ${packet.toString('hex')}`);
173
-
174
- // Process the packet same as UDP
175
- this._handlePacket(packet, 'tcp', socket);
176
-
177
- // Continue with remaining buffer
178
- buffer = buffer.slice(endIdx + 1);
179
- }
180
-
181
- callback(buffer);
182
- }
183
-
184
- /**
185
- * Stop the client and close the session gracefully
186
- */
187
- async stop() {
188
- if (this.session && this.handshakeState === 'ESTABLISHED') {
189
- try {
190
- const endSessionPacket = this.session.buildEndSession();
191
- this._sendPacket(endSessionPacket);
192
- this._log('[Shutdown] END_SESSION sent to panel');
193
- } catch (err) {
194
- this._log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
195
- }
196
- }
197
-
198
- if (this.server) {
199
- await new Promise((resolve) => {
200
- this.server.close(() => {
201
- this._log('[Shutdown] UDP server closed');
202
- resolve();
203
- });
204
- });
205
- this.server = null;
206
- }
207
-
208
- // Close TCP server
209
- if (this.tcpServer) {
210
- // Close all TCP client connections
211
- for (const [clientId, socket] of this.tcpClients) {
212
- socket.destroy();
213
- }
214
- this.tcpClients.clear();
215
-
216
- await new Promise((resolve) => {
217
- this.tcpServer.close(() => {
218
- this._log('[Shutdown] TCP server closed');
219
- resolve();
220
- });
221
- });
222
- this.tcpServer = null;
223
- }
224
-
225
- // Clear session and encryption state to prevent stale keys on reconnect
226
- if (this.session) {
227
- this.session.disableAes();
228
- this.session = null;
229
- }
230
- this.handshakeState = 'WAITING';
231
- this.panelAddress = null;
232
- this.panelPort = null;
233
- this.detectedEncryptionType = null;
234
-
235
- // Remove signal handlers to prevent stacking on restart
236
- if (this._boundShutdown) {
237
- process.removeListener('SIGINT', this._boundShutdown);
238
- process.removeListener('SIGTERM', this._boundShutdown);
239
- this._boundShutdown = null;
240
- }
241
-
242
- this.emit('session:closed');
243
- }
244
-
245
- /**
246
- * Arm partition in stay mode
247
- */
248
- armStay(partition, code) {
249
- if (!this._checkEstablished()) return;
250
- const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.STAY, code || this.masterCode);
251
- this._sendPacket(packet);
252
- }
253
-
254
- /**
255
- * Arm partition in away mode
256
- */
257
- armAway(partition, code) {
258
- if (!this._checkEstablished()) return;
259
- const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.AWAY, code || this.masterCode);
260
- this._sendPacket(packet);
261
- }
262
-
263
- /**
264
- * Arm partition in night mode
265
- */
266
- armNight(partition, code) {
267
- if (!this._checkEstablished()) return;
268
- const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NIGHT, code || this.masterCode);
269
- this._sendPacket(packet);
270
- }
271
-
272
- /**
273
- * Disarm partition
274
- */
275
- disarm(partition, code) {
276
- if (!this._checkEstablished()) return;
277
- const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
278
- this._sendPacket(packet);
279
- }
280
-
281
- // ==================== Status Query Methods ====================
282
-
283
- /**
284
- * Query global system status (0x0810)
285
- * Note: May not be supported by all panels
286
- */
287
- queryGlobalStatus() {
288
- if (!this._checkEstablished()) return;
289
- const packet = this.session.buildGlobalStatusRequest();
290
- this._sendPacket(packet);
291
- }
292
-
293
- /**
294
- * Query zone status (0x0811)
295
- * @param {number} zone - Zone number (0 = all zones)
296
- * Note: May not be supported by all panels
297
- */
298
- queryZoneStatus(zone = 0) {
299
- if (!this._checkEstablished()) return;
300
- const packet = this.session.buildZoneStatusRequest(zone);
301
- this._sendPacket(packet);
302
- }
303
-
304
- /**
305
- * Query partition status (0x0812)
306
- * @param {number} partition - Partition number (0 = all partitions)
307
- * Note: May not be supported by all panels
308
- */
309
- queryPartitionStatus(partition = 0) {
310
- if (!this._checkEstablished()) return;
311
- const packet = this.session.buildPartitionStatusRequest(partition);
312
- this._sendPacket(packet);
313
- }
314
-
315
- /**
316
- * Query zone bypass status (0x0813)
317
- * @param {number} zone - Zone number (0 = all zones)
318
- * Note: May not be supported by all panels
319
- */
320
- queryZoneBypassStatus(zone = 0) {
321
- if (!this._checkEstablished()) return;
322
- const packet = this.session.buildZoneBypassStatusRequest(zone);
323
- this._sendPacket(packet);
324
- }
325
-
326
- /**
327
- * Query system trouble status (0x0814)
328
- * Note: May not be supported by all panels
329
- */
330
- queryTroubleStatus() {
331
- if (!this._checkEstablished()) return;
332
- const packet = this.session.buildTroubleStatusRequest();
333
- this._sendPacket(packet);
334
- }
335
-
336
- /**
337
- * Send a raw command directly (not wrapped in 0x0800)
338
- * Useful for testing different query formats
339
- * @param {number} command - Command code (e.g., 0x0812)
340
- * @param {Buffer} [payload] - Optional payload data
341
- */
342
- sendDirect(command, payload = null) {
343
- if (!this._checkEstablished()) return;
344
- const packet = this.session.buildCommand(command, payload);
345
- this._logMinimal(`[Direct] Sending command 0x${command.toString(16).padStart(4, '0')} ${payload ? 'with ' + payload.length + ' bytes payload' : 'no payload'}`);
346
- this._sendPacket(packet);
347
- }
348
-
349
- // ==================== Authentication Methods ====================
350
-
351
- /**
352
- * Authenticate with access code - simple format (0x0400)
353
- * Uses 6-digit ASCII format matching interactive CLI
354
- * @param {string} code - User/master code (e.g., "5555")
355
- */
356
- authenticate(code) {
357
- if (!this._checkEstablished()) return;
358
- const codeStr = (code || this.masterCode).toString().padStart(6, '0').slice(0, 6);
359
- const payload = Buffer.from(codeStr, 'ascii');
360
- const packet = this.session.buildCommand(0x0400, payload);
361
- this._sendPacket(packet);
362
- }
363
-
364
- /**
365
- * Authenticate with access code - extended format (0x0400)
366
- * Includes partition and access level metadata
367
- * @param {number} partition - Partition number (1-8, or 0 for all)
368
- * @param {string} code - User/master code (e.g., "5555")
369
- * @param {number} accessLevel - 0=User, 1=Installer, 2=Master
370
- */
371
- authenticateExtended(partition = 1, code, accessLevel = 0) {
372
- if (!this._checkEstablished()) return;
373
- const packet = this.session.buildAccessLevelEnter(
374
- code || this.masterCode,
375
- partition,
376
- accessLevel,
377
- 'bcd'
378
- );
379
- this._sendPacket(packet);
380
- }
381
-
382
- /**
383
- * Exit authenticated access level (0x0401)
384
- * @param {number} partition - Partition number
385
- */
386
- deauthenticate(partition = 1) {
387
- if (!this._checkEstablished()) return;
388
- const packet = this.session.buildAccessLevelExit(partition);
389
- this._sendPacket(packet);
390
- }
391
-
392
- // ==================== State Accessors ====================
393
-
394
- /**
395
- * Get current zone states
396
- */
397
- getZones() {
398
- return this.eventHandler.getAllZones();
399
- }
400
-
401
- /**
402
- * Get current partition states
403
- */
404
- getPartitions() {
405
- return this.eventHandler.getAllPartitions();
406
- }
407
-
408
- // ==================== Internal Methods ====================
409
-
410
- _checkEstablished() {
411
- if (this.handshakeState !== 'ESTABLISHED') {
412
- this.emit('error', new Error('Session not established'));
413
- return false;
414
- }
415
- return true;
416
- }
417
-
418
- _sendPacket(packet) {
419
- // Use the same transport that the last packet came from
420
- if (this._lastTransport === 'tcp' && this._lastTcpSocket) {
421
- this._log(`\n[TCP] TX (${packet.length} bytes)`);
422
- if (this.debug) {
423
- this._hexDump(packet);
424
- }
425
- this._lastTcpSocket.write(packet);
426
- } else {
427
- // UDP (default)
428
- if (!this.panelAddress || !this.panelPort) {
429
- this._log('[Error] No panel address/port set');
430
- return;
431
- }
432
-
433
- this._log(`\n[UDP] TX to ${this.panelAddress}:${this.panelPort} (${packet.length} bytes)`);
434
- if (this.debug) {
435
- this._hexDump(packet);
436
- }
437
- this.server.send(packet, this.panelPort, this.panelAddress);
438
- }
439
- }
440
-
441
- _handlePacket(data, transport = 'udp', tcpSocket = null) {
442
- try {
443
- if (!this.session) {
444
- // Create session with appropriate logger based on log level
445
- const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {
446
- };
447
- this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
448
- }
449
-
450
- const parsed = this.session.parsePacket(data);
451
- if (!parsed) {
452
- this._log('[Error] Failed to parse packet');
453
- return;
454
- }
455
-
456
- // Store transport info for responses
457
- this._lastTransport = transport;
458
- this._lastTcpSocket = tcpSocket;
459
-
460
- // Verbose logging
461
- const source = transport === 'tcp' ? 'TCP' : `UDP ${this.panelAddress}:${this.panelPort}`;
462
- this._log(`\n[${transport.toUpperCase()}] RX from ${source} (${data.length} bytes)`);
463
- this._hexDump(data);
464
- this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
465
- this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
466
- this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
467
- if (parsed.appSequence !== null) {
468
- this._log(`[Packet] App Seq: ${parsed.appSequence}`);
469
- }
470
- if (parsed.commandData) {
471
- this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
472
- }
473
-
474
- // Route based on command
475
- this._routePacket(parsed);
476
-
477
- } catch (err) {
478
- // During WAITING state, parse errors are expected (stale encrypted packets from previous session)
479
- if (this.handshakeState === 'WAITING') {
480
- this._log(`[Recovery] Ignoring malformed packet while waiting for fresh handshake: ${err.message}`);
481
- // Reset session to ensure we start fresh on next valid packet
482
- if (this.session) {
483
- this.session.disableAes();
484
- this.session = null;
485
- }
486
- return;
487
- }
488
-
489
- this._log(`[Error] ${err.message}`);
490
- this.emit('error', err);
491
-
492
- // Reset on parse errors during handshake
493
- if (this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
494
- this._log('[Recovery] Parse error, resetting to wait for panel restart');
495
- if (this.session) {
496
- this.session.disableAes();
497
- this.session = null;
498
- }
499
- this.handshakeState = 'WAITING';
500
- }
501
- }
502
- }
503
-
504
- _routePacket(parsed) {
505
- const cmd = parsed.command;
506
-
507
- // Handle ACKs
508
- if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
509
- this._handleSimpleAck(parsed);
510
- return;
511
- }
512
-
513
- // Route based on command type
514
- switch (cmd) {
515
- case CMD.OPEN_SESSION:
516
- this._handleOpenSession(parsed);
517
- break;
518
- case CMD.REQUEST_ACCESS:
519
- this._handleRequestAccess(parsed);
520
- break;
521
- case CMD.COMMAND_RESPONSE:
522
- this._handleCommandResponse(parsed);
523
- break;
524
- case CMD.COMMAND_ERROR:
525
- this._handleCommandError(parsed);
526
- break;
527
- case CMD.POLL:
528
- this._handlePoll(parsed);
529
- break;
530
- case CMD.LIFESTYLE_ZONE_STATUS:
531
- this._handleLifestyleZoneStatus(parsed);
532
- break;
533
- case CMD.TIME_DATE_BROADCAST:
534
- this._handleTimeDateBroadcast(parsed);
535
- break;
536
- case CMD.ZONE_STATUS:
537
- this._handleZoneStatusResponse(parsed);
538
- break;
539
- case CMD.PARTITION_STATUS:
540
- this._handlePartitionStatusResponse(parsed);
541
- break;
542
- case CMD.GLOBAL_STATUS:
543
- this._handleGlobalStatusResponse(parsed);
544
- break;
545
- case CMD.SYSTEM_TROUBLE_STATUS:
546
- this._handleTroubleStatusResponse(parsed);
547
- break;
548
- default:
549
- if (this.handshakeState === 'ESTABLISHED') {
550
- const cmdHex = cmd ? '0x' + cmd.toString(16).padStart(4, '0') : 'null';
551
- const cmdName = CMD_NAMES[cmd] || 'UNKNOWN';
552
- this._logMinimal(`[Session] Received command ${cmdName} (${cmdHex})`);
553
- if (parsed.commandData && parsed.commandData.length > 0) {
554
- this._logMinimal(`[Session] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex')}`);
555
- }
556
- this._sendPacket(this.session.buildSimpleAck());
557
- }
558
- break;
559
- }
560
- }
561
-
562
- // ==================== Handshake Handlers ====================
563
-
564
- _handleOpenSession(parsed) {
565
- const data = parsed.commandData;
566
- if (!data || data.length < 14) {
567
- this._log('[Error] Invalid OPEN_SESSION data');
568
- return;
569
- }
570
-
571
- const deviceType = data[0];
572
- const deviceId = data.readUInt16BE(1);
573
- const firmware = data.readUInt16BE(3);
574
- const protocol = data.readUInt16BE(5);
575
- const txBuffer = data.readUInt16BE(7);
576
- const rxBuffer = data.readUInt16BE(9);
577
- const encryptionType = data[13];
578
-
579
- this._log(`[Session] OPEN_SESSION received:`);
580
- this._log(` Device Type: ${deviceType}`);
581
- this._log(` Encryption Type: ${encryptionType}`);
582
-
583
- // Emit connecting event now that we have a valid OPEN_SESSION
584
- this.emit('session:connecting');
585
- this._logMinimal(`[Session] Panel connecting from ${this.panelAddress}`);
586
- this._logMinimal('[Handshake] Starting session establishment...');
587
-
588
- // Handle panel restart mid-handshake
589
- if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
590
- this._log(`[Session] Panel restarting handshake (was in state ${this.handshakeState}), resetting session`);
591
- this.session = new ITv2Session(this.integrationId, this.accessCode, this._log.bind(this));
592
- }
593
-
594
- // Send COMMAND_RESPONSE echoing device type
595
- this._log(`[Handshake] Sending COMMAND_RESPONSE success (echoing device type ${deviceType})`);
596
- const response = this.session.buildCommandResponseWithAppSeq(deviceType, 0x00);
597
- this._sendPacket(response);
598
- this.handshakeState = 'SENT_CMD_RESPONSE_1';
599
- }
600
-
601
- _handleSimpleAck(parsed) {
602
- const expectedSeq = this.session.localSequence;
603
- this._log(`[Handshake] Got ACK ${parsed.receiverSequence}, state=${this.handshakeState}`);
604
-
605
- if (this.handshakeState === 'SENT_CMD_RESPONSE_1') {
606
- // Panel ACKed our first COMMAND_RESPONSE, send OPEN_SESSION
607
- this._log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
608
- const openSessionPayload = Buffer.from([
609
- 0x01, 0x80, 0x00, 0x00, // Device ID/type
610
- 0x02, 0x01, // Firmware
611
- 0x02, 0x41, // Protocol version
612
- 0x02, 0x00, // TX buffer
613
- 0x02, 0x00, // RX buffer
614
- 0x00, 0x01, 0x01 // Capabilities/encryption
615
- ]);
616
- const packet = this.session.buildOpenSessionResponse(openSessionPayload);
617
- this._sendPacket(packet);
618
- this.handshakeState = 'SENT_OPEN_SESSION';
619
- } else if (this.handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
620
- // Panel ACKed our COMMAND_RESPONSE to their REQUEST_ACCESS
621
- this._log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
622
-
623
- // Enable encryption with SEND key
624
- this.session.sendAesActive = true;
625
- this.session.sendAesKey = this.session.derivedSendKey;
626
- this._log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key`);
627
-
628
- // Build REQUEST_ACCESS based on detected encryption type
629
- let reqAccessPacket;
630
- if (this.detectedEncryptionType === 2) {
631
- this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
632
- reqAccessPacket = this.session.buildRequestAccessType2();
633
- } else {
634
- this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
635
- reqAccessPacket = this.session.buildRequestAccessType1();
636
- }
637
-
638
- this._sendPacket(reqAccessPacket);
639
-
640
- // Enable receive encryption
641
- this.session.receiveAesActive = true;
642
- this.session.receiveAesKey = this.session.pendingReceiveKey;
643
- this._log(`[Handshake] Enabled receive encryption`);
644
-
645
- this.handshakeState = 'SENT_REQUEST_ACCESS';
646
- } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
647
- // Panel ACKed our REQUEST_ACCESS - session established!
648
- this.handshakeState = 'ESTABLISHED';
649
- this._logMinimal(`[Handshake] Session established (Type ${this.detectedEncryptionType} encryption)`);
650
- this._log(`[Handshake] *** SESSION ESTABLISHED ***`);
651
- this._log(`[Handshake] Encryption Type: ${this.detectedEncryptionType}`);
652
- this._log(`[Handshake] SEND key: ${this.session.derivedSendKey?.toString('hex')}`);
653
- this._log(`[Handshake] RECV key: ${this.session.pendingReceiveKey?.toString('hex')}`);
654
-
655
- this.emit('session:established', {
656
- encryptionType: this.detectedEncryptionType,
657
- sendKey: this.session.derivedSendKey?.toString('hex'),
658
- recvKey: this.session.pendingReceiveKey?.toString('hex')
659
- });
660
- }
661
- }
662
-
663
- _handleRequestAccess(parsed) {
664
- const data = parsed.commandData;
665
- if (!data || data.length < 17) {
666
- this._log('[Error] Invalid REQUEST_ACCESS data');
667
- return;
668
- }
669
-
670
- const initType = data[0];
671
- const initializer = data.slice(1);
672
-
673
- this._log(`[Handshake] Got panel's REQUEST_ACCESS`);
674
- this._log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
675
-
676
- // Determine encryption type based on initializer length
677
- if (initializer.length === 48) {
678
- // Type 1 encryption
679
- this.detectedEncryptionType = 1;
680
- this._log(`[Handshake] Encryption Type 1 (48-byte initializer)`);
681
-
682
- const sendKey = parseType1Initializer(this.accessCode, initializer, this.integrationId, this.logLevel === 'verbose');
683
- this._logMinimal('[Handshake] Type 1 encryption negotiated');
684
- this._log(`[Handshake] Type 1 SEND key derived`);
685
- this.session.derivedSendKey = sendKey;
686
-
687
- } else if (initializer.length === 16) {
688
- // Type 2 encryption
689
- this.detectedEncryptionType = 2;
690
- this._log(`[Handshake] Encryption Type 2 (16-byte initializer)`);
691
-
692
- // Type 2: Symmetric transform with 32-hex access code
693
- let accessCode32 = this.accessCode;
694
- if (accessCode32.length === 8) {
695
- accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
696
- }
697
-
698
- const sendKey = type2InitializerTransform(accessCode32, initializer);
699
- this._logMinimal('[Handshake] Type 2 encryption negotiated');
700
- this._log(`[Handshake] Type 2 SEND key derived`);
701
- this.session.derivedSendKey = sendKey;
702
-
703
- } else {
704
- this._log(`[Error] Unknown initializer length: ${initializer.length}`);
705
- return;
706
- }
707
-
708
- // Send plaintext COMMAND_RESPONSE
709
- this._log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
710
- const appSeq = parsed.appSequence;
711
- const response = this.session.buildCommandResponseWithAppSeq(appSeq, 0x00);
712
- this._sendPacket(response);
713
-
714
- // Send our REQUEST_ACCESS immediately (panel doesn't ACK our COMMAND_RESPONSE)
715
- this._logMinimal('[Handshake] Sending our REQUEST_ACCESS...');
716
-
717
- // Enable encryption with SEND key
718
- this.session.sendAesActive = true;
719
- this.session.sendAesKey = this.session.derivedSendKey;
720
- this._log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key`);
721
-
722
- // Build REQUEST_ACCESS based on detected encryption type
723
- let reqAccessPacket;
724
- if (this.detectedEncryptionType === 2) {
725
- this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
726
- reqAccessPacket = this.session.buildRequestAccessType2();
727
- } else {
728
- this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
729
- reqAccessPacket = this.session.buildRequestAccessType1();
730
- }
731
-
732
- this._sendPacket(reqAccessPacket);
733
-
734
- // Enable receive encryption
735
- this.session.receiveAesActive = true;
736
- this.session.receiveAesKey = this.session.pendingReceiveKey;
737
- this._log(`[Handshake] Enabled receive encryption`);
738
-
739
- this.handshakeState = 'SENT_REQUEST_ACCESS';
740
- }
741
-
742
- _handleCommandResponse(parsed) {
743
- const data = parsed.commandData;
744
- const responseCode = data?.[0] || 0;
745
- const appSeqAsEcho = parsed.appSequence;
746
-
747
- this._log(`[Session] Got COMMAND_RESPONSE (app_seq: ${appSeqAsEcho}, response_code: ${responseCode})`);
748
-
749
- // Log full data in established state for debugging query responses
750
- if (this.handshakeState === 'ESTABLISHED' && data && data.length > 1) {
751
- this._logMinimal(`[Command Response] Response code: ${responseCode}, data (${data.length} bytes): ${data.toString('hex')}`);
752
- this.emit('command:response', { responseCode, appSequence: appSeqAsEcho, data });
753
- }
754
-
755
- const ack = this.session.buildSimpleAck();
756
- this._sendPacket(ack);
757
-
758
- if (this.handshakeState === 'SENT_OPEN_SESSION') {
759
- // Panel accepted our OPEN_SESSION
760
- this.handshakeState = 'WAITING_REQUEST_ACCESS';
761
- } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
762
- // Panel accepted our REQUEST_ACCESS - session established!
763
- this.handshakeState = 'ESTABLISHED';
764
- this._logMinimal(`[Handshake] Session established (Type ${this.detectedEncryptionType} encryption)`);
765
- this.emit('session:established', {
766
- encryptionType: this.detectedEncryptionType,
767
- sendKey: this.session.derivedSendKey?.toString('hex'),
768
- recvKey: this.session.pendingReceiveKey?.toString('hex')
769
- });
770
- }
771
- }
772
-
773
- _handleCommandError(parsed) {
774
- const errorData = parsed.commandData;
775
- const error = parseCommandError(errorData);
776
-
777
- this._log(`[Error] COMMAND_ERROR: ${error.message} (${error.rawData})`);
778
- this.emit('command:error', error);
779
-
780
- // Send ACK
781
- this._sendPacket(this.session.buildSimpleAck());
782
- }
783
-
784
- _handlePoll(parsed) {
785
- this._log(`[Session] Got POLL, sending ACK`);
786
- this._sendPacket(this.session.buildSimpleAck());
787
- }
788
-
789
- // ==================== Notification Handlers ====================
790
-
791
- _handleLifestyleZoneStatus(parsed) {
792
- // If we receive encrypted zone data, session is established (even if we missed the final ACK)
793
- if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
794
- this.handshakeState = 'ESTABLISHED';
795
- this._logMinimal(`[Handshake] Session established (Type ${this.detectedEncryptionType} encryption)`);
796
- this.emit('session:established', {
797
- encryptionType: this.detectedEncryptionType,
798
- sendKey: this.session.derivedSendKey?.toString('hex'),
799
- recvKey: this.session.pendingReceiveKey?.toString('hex')
800
- });
801
- }
802
-
803
- const data = parsed.commandData;
804
-
805
- if (data && data.length >= 2) {
806
- const zoneNum = data[0];
807
- const statusValue = data[1];
808
-
809
- // Update internal state and get full parsed status
810
- const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
811
-
812
- // Emit events with full status object
813
- this.emit('zone:status', zoneNum, fullStatus);
814
-
815
- if (fullStatus.open) {
816
- this.emit('zone:open', zoneNum);
817
- this._log(`[Zone ${zoneNum}] OPEN (status: ${statusValue})`);
818
- } else {
819
- this.emit('zone:closed', zoneNum);
820
- this._log(`[Zone ${zoneNum}] CLOSED (status: ${statusValue})`);
821
- }
822
- }
823
-
824
- this._sendPacket(this.session.buildSimpleAck());
825
- }
826
-
827
- _handleTimeDateBroadcast(parsed) {
828
- const data = parsed.commandData;
829
- if (data && data.length >= 5) {
830
- this._log(`[Time/Date] Received broadcast`);
831
- this.emit('notification:timeDate', data);
832
- }
833
- this._sendPacket(this.session.buildSimpleAck());
834
- }
835
-
836
- // ==================== Status Query Response Handlers ====================
837
-
838
- _handleZoneStatusResponse(parsed) {
839
- const data = parsed.commandData;
840
- this._logMinimal(`[Zone Status] Received zone status response`);
841
- this._log(`[Zone Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
842
-
843
- if (data && data.length >= 3) {
844
- // Response format: [ZoneNumber 2B BE][StatusByte 1B][...more zones...]
845
- const zoneNum = data.readUInt16BE(0);
846
- const statusByte = data[2];
847
-
848
- // Update internal state
849
- const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusByte);
850
-
851
- this._logMinimal(`[Zone Status] Zone ${zoneNum}: ${fullStatus.open ? 'OPEN' : 'CLOSED'} (0x${statusByte.toString(16)})`);
852
-
853
- // Emit event with full status
854
- this.emit('zone:statusResponse', zoneNum, fullStatus);
855
- this.emit('zone:status', zoneNum, fullStatus);
856
- } else {
857
- this._log(`[Zone Status] Empty or invalid response`);
858
- }
859
-
860
- this._sendPacket(this.session.buildSimpleAck());
861
- }
862
-
863
- _handlePartitionStatusResponse(parsed) {
864
- const data = parsed.commandData;
865
- this._logMinimal(`[Partition Status] Received partition status response`);
866
- this._log(`[Partition Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
867
-
868
- if (data && data.length >= 3) {
869
- // Response format: [PartitionNumber 2B BE][StatusBytes...]
870
- const partitionNum = data.readUInt16BE(0);
871
- const statusBytes = data.slice(2);
872
-
873
- this._logMinimal(`[Partition Status] Partition ${partitionNum}: status bytes ${statusBytes.toString('hex')}`);
874
-
875
- // Emit raw event - let consumers parse the detailed status
876
- this.emit('partition:statusResponse', partitionNum, statusBytes);
877
- } else {
878
- this._log(`[Partition Status] Empty or invalid response`);
879
- }
880
-
881
- this._sendPacket(this.session.buildSimpleAck());
882
- }
883
-
884
- _handleGlobalStatusResponse(parsed) {
885
- const data = parsed.commandData;
886
- this._logMinimal(`[Global Status] Received global status response`);
887
- this._log(`[Global Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
888
-
889
- if (data && data.length > 0) {
890
- // Emit raw event with full data
891
- this.emit('global:statusResponse', data);
892
-
893
- // Log byte breakdown for debugging
894
- this._log(`[Global Status] Byte breakdown:`);
895
- for (let i = 0; i < data.length && i < 32; i++) {
896
- this._log(` Byte ${i}: 0x${data[i].toString(16).padStart(2, '0')} (${data[i]})`);
897
- }
898
- } else {
899
- this._log(`[Global Status] Empty response`);
900
- }
901
-
902
- this._sendPacket(this.session.buildSimpleAck());
903
- }
904
-
905
- _handleTroubleStatusResponse(parsed) {
906
- const data = parsed.commandData;
907
- this._logMinimal(`[Trouble Status] Received trouble status response`);
908
- this._log(`[Trouble Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
909
-
910
- if (!data || data.length === 0) {
911
- this._logMinimal(`[Trouble Status] No active troubles`);
912
- this.emit('trouble:statusResponse', []);
913
- this._sendPacket(this.session.buildSimpleAck());
914
- return;
915
- }
916
-
917
- // Try to parse as VarBytes device/trouble list:
918
- // [VarByte:DeviceType][Count][VarByte:TroubleType]... repeated
919
- const troubles = [];
920
- let offset = 0;
921
-
922
- try {
923
- while (offset < data.length) {
924
- const device = ITv2Session.decodeVarBytes(data, offset);
925
- offset += device.bytesRead;
926
-
927
- const troubleCount = data[offset];
928
- offset += 1;
929
-
930
- const troubleTypes = [];
931
- for (let i = 0; i < troubleCount; i++) {
932
- const trouble = ITv2Session.decodeVarBytes(data, offset);
933
- offset += trouble.bytesRead;
934
- troubleTypes.push(trouble.value);
935
- }
936
-
937
- troubles.push({ deviceType: device.value, troubles: troubleTypes });
938
- this._logMinimal(`[Trouble Status] Device ${device.value}: troubles [${troubleTypes.join(', ')}]`);
939
- }
940
- } catch (e) {
941
- this._logMinimal(`[Trouble Status] Parse error at offset ${offset}: ${e.message}`);
942
- this._logMinimal(`[Trouble Status] Raw hex: ${data.toString('hex')}`);
943
- }
944
-
945
- this.emit('trouble:statusResponse', troubles);
946
- this._sendPacket(this.session.buildSimpleAck());
947
- }
948
-
949
- // ==================== Utility Methods ====================
950
-
951
- _handleError(error) {
952
- this._log(`[Error] ${error.message}`);
953
- this.emit('error', error);
954
- }
955
-
956
- _log(message) {
957
- if (this.logLevel === 'verbose') {
958
- const timestamp = new Date().toISOString();
959
- console.log(`dsc itv2: [${timestamp}] ${message}`);
960
- }
961
- }
962
-
963
- _logMinimal(message) {
964
- if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
965
- console.log(`dsc itv2: ${message}`);
966
- }
967
- }
968
-
969
- _hexDump(buffer) {
970
- if (this.logLevel !== 'verbose') return;
971
-
972
- for (let i = 0; i < buffer.length; i += 16) {
973
- const chunk = buffer.slice(i, i + 16);
974
- const hex = Array.from(chunk)
975
- .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
976
- .join(' ');
977
- const ascii = Array.from(chunk)
978
- .map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
979
- .join('');
980
-
981
- const offset = i.toString(16).padStart(4, '0').toUpperCase();
982
- console.log(`dsc itv2: ${offset} ${hex.padEnd(48)} |${ascii}|`);
983
- }
984
- }
985
- }
1
+ /**
2
+ * DSC ITV2 Protocol Client
3
+ * Event-driven client for DSC alarm panels using the ITV2 protocol
4
+ */
5
+
6
+ import {EventEmitter} from 'events';
7
+ import net from 'net';
8
+ import {ITv2Session, CMD, CMD_NAMES} from './itv2-session.js';
9
+ import {parseType1Initializer, type2InitializerTransform} from './itv2-crypto.js';
10
+ import {EventHandler} from './event-handler.js';
11
+ import {parseCommandError} from './response-parsers.js';
12
+
13
+ /**
14
+ * ITV2Client - Main library class
15
+ * Handles DSC panel communication and emits events for status updates
16
+ */
17
+ export class ITV2Client extends EventEmitter {
18
+ constructor(options = {}) {
19
+ super();
20
+
21
+ // Configuration
22
+ this.integrationId = options.integrationId;
23
+ this.accessCode = options.accessCode;
24
+ this.masterCode = options.masterCode || '5555';
25
+ this.port = options.port || 3072; // TCP port (panel connects to us)
26
+ this.logLevel = options.logLevel || (options.debug === true ? 'verbose' : 'minimal'); // 'silent', 'minimal', 'verbose'
27
+ this.encryptionType = options.encryptionType || null; // null = auto-detect, 1 = Type 1, 2 = Type 2
28
+
29
+ // Internal state
30
+ this.tcpServer = null; // TCP server
31
+ this.tcpSocket = null; // Active panel TCP connection
32
+ this.session = null;
33
+ this.eventHandler = new EventHandler();
34
+ this.handshakeState = 'WAITING';
35
+ this.panelAddress = null;
36
+ this.detectedEncryptionType = null; // Set during handshake
37
+ this._panelOpenSessionData = null; // Saved panel OPEN_SESSION payload for echoing
38
+ this._heartbeatInterval = null; // Heartbeat timer
39
+ this._sessionReady = false; // True after initial message burst subsides
40
+ this._readyTimer = null; // Timer for session readiness detection
41
+ this._pendingRequests = new Map(); // command code { resolve, reject, timer }
42
+ this._capabilities = null; // Cached from initial capabilities query
43
+
44
+ // Bind methods
45
+ this._handlePacket = this._handlePacket.bind(this);
46
+ this._handleError = this._handleError.bind(this);
47
+ this._boundShutdown = null; // Track signal handler for cleanup
48
+ }
49
+
50
+ /**
51
+ * Start the TCP server and listen for panel connection
52
+ * The panel initiates a TCP connection to us on port 3072 (default)
53
+ */
54
+ async start() {
55
+ return new Promise((resolve, reject) => {
56
+ this.tcpServer = net.createServer((socket) => {
57
+ const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
58
+ this._logMinimal(`[TCP] Panel connected: ${clientId}`);
59
+
60
+ this.panelAddress = socket.remoteAddress;
61
+ this.tcpSocket = socket;
62
+
63
+ let buffer = Buffer.alloc(0);
64
+
65
+ socket.on('data', (data) => {
66
+ this._log(`[TCP] Received ${data.length} bytes from ${clientId}`);
67
+
68
+ // Accumulate data in buffer
69
+ buffer = Buffer.concat([buffer, data]);
70
+
71
+ // Parse complete packets (0x7E...0x7F framing)
72
+ buffer = this._processTcpBuffer(buffer);
73
+ });
74
+
75
+ socket.on('close', () => {
76
+ this._logMinimal(`[TCP] Panel disconnected: ${clientId}`);
77
+ this.tcpSocket = null;
78
+ this.handshakeState = 'WAITING';
79
+ if (this.session) {
80
+ this.session.disableAes();
81
+ this.session = null;
82
+ }
83
+ this.emit('session:closed');
84
+ });
85
+
86
+ socket.on('error', (err) => {
87
+ this._log(`[TCP] Socket error for ${clientId}: ${err.message}`);
88
+ this.tcpSocket = null;
89
+ });
90
+ });
91
+
92
+ this.tcpServer.on('error', (err) => {
93
+ this._log(`[Error] TCP server error: ${err.message}`);
94
+ this.emit('error', err);
95
+ reject(err);
96
+ });
97
+
98
+ this.tcpServer.listen(this.port, '0.0.0.0', () => {
99
+ const address = this.tcpServer.address();
100
+ this._logMinimal(`[TCP] Server listening on port ${address.port}`);
101
+ this.emit('listening', address);
102
+ resolve();
103
+ });
104
+
105
+ // Register signal handlers (only once)
106
+ if (!this._boundShutdown) {
107
+ this._boundShutdown = async () => {
108
+ await this.stop();
109
+ process.exit(0);
110
+ };
111
+ process.on('SIGINT', this._boundShutdown);
112
+ process.on('SIGTERM', this._boundShutdown);
113
+ }
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Process TCP buffer looking for complete ITV2 packets
119
+ * Returns remaining unprocessed buffer
120
+ */
121
+ _processTcpBuffer(buffer) {
122
+ while (buffer.length > 2) {
123
+ // Find start marker (0x7E)
124
+ const startIdx = buffer.indexOf(0x7E);
125
+ if (startIdx === -1) {
126
+ // No start marker, keep last 12 bytes in case integration ID is split
127
+ return buffer.slice(Math.max(0, buffer.length - 12));
128
+ }
129
+
130
+ // Find end marker (0x7F) after start
131
+ const endIdx = buffer.indexOf(0x7F, startIdx + 1);
132
+ if (endIdx === -1) {
133
+ // No end marker yet, wait for more data
134
+ // Keep from the integration ID prefix (up to 12 bytes before 0x7E)
135
+ return buffer.slice(Math.max(0, startIdx - 12));
136
+ }
137
+
138
+ // Extract complete packet (integration ID prefix + 0x7E...0x7F)
139
+ const packetStart = Math.max(0, startIdx - 12);
140
+ const packet = buffer.slice(packetStart, endIdx + 1);
141
+
142
+ this._log(`[TCP] Complete packet (${packet.length} bytes)`);
143
+ this._handlePacket(packet);
144
+
145
+ // Continue with remaining buffer
146
+ buffer = buffer.slice(endIdx + 1);
147
+ }
148
+
149
+ return buffer;
150
+ }
151
+
152
+ /**
153
+ * Stop the client and close the session gracefully
154
+ */
155
+ async stop() {
156
+ if (this.session && this.handshakeState === 'ESTABLISHED') {
157
+ try {
158
+ const endSessionPacket = this.session.buildEndSession();
159
+ this._sendPacket(endSessionPacket);
160
+ this._log('[Shutdown] END_SESSION sent to panel');
161
+ } catch (err) {
162
+ this._log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
163
+ }
164
+ }
165
+
166
+ // Close panel socket
167
+ if (this.tcpSocket) {
168
+ this.tcpSocket.destroy();
169
+ this.tcpSocket = null;
170
+ }
171
+
172
+ // Close TCP server
173
+ if (this.tcpServer) {
174
+ await new Promise((resolve) => {
175
+ this.tcpServer.close(() => {
176
+ this._log('[Shutdown] TCP server closed');
177
+ resolve();
178
+ });
179
+ });
180
+ this.tcpServer = null;
181
+ }
182
+
183
+ // Clear heartbeat
184
+ if (this._heartbeatInterval) {
185
+ clearInterval(this._heartbeatInterval);
186
+ this._heartbeatInterval = null;
187
+ }
188
+ if (this._readyTimer) {
189
+ clearTimeout(this._readyTimer);
190
+ this._readyTimer = null;
191
+ }
192
+ this._sessionReady = false;
193
+
194
+ // Clear pending requests
195
+ for (const [, pending] of this._pendingRequests) {
196
+ clearTimeout(pending.timer);
197
+ pending.reject(new Error('Session closed'));
198
+ }
199
+ this._pendingRequests.clear();
200
+
201
+ // Clear session and encryption state to prevent stale keys on reconnect
202
+ if (this.session) {
203
+ this.session.disableAes();
204
+ this.session = null;
205
+ }
206
+ this.handshakeState = 'WAITING';
207
+ this.panelAddress = null;
208
+ this.detectedEncryptionType = null;
209
+ this._panelOpenSessionData = null;
210
+
211
+ // Remove signal handlers to prevent stacking on restart
212
+ if (this._boundShutdown) {
213
+ process.removeListener('SIGINT', this._boundShutdown);
214
+ process.removeListener('SIGTERM', this._boundShutdown);
215
+ this._boundShutdown = null;
216
+ }
217
+
218
+ this.emit('session:closed');
219
+ }
220
+
221
+ /**
222
+ * Arm partition in stay mode (mode 1)
223
+ */
224
+ armStay(partition, code) {
225
+ if (!this._checkEstablished()) return;
226
+ const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.STAY, code || this.masterCode);
227
+ this._sendPacket(packet);
228
+ }
229
+
230
+ /**
231
+ * Arm partition in away mode (mode 2)
232
+ */
233
+ armAway(partition, code) {
234
+ if (!this._checkEstablished()) return;
235
+ const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.AWAY, code || this.masterCode);
236
+ this._sendPacket(packet);
237
+ }
238
+
239
+ /**
240
+ * Arm partition in night mode (mode 4)
241
+ */
242
+ armNight(partition, code) {
243
+ if (!this._checkEstablished()) return;
244
+ const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NIGHT, code || this.masterCode);
245
+ this._sendPacket(packet);
246
+ }
247
+
248
+ /**
249
+ * Arm partition with no entry delay (mode 3)
250
+ */
251
+ armNoEntryDelay(partition, code) {
252
+ if (!this._checkEstablished()) return;
253
+ const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NO_ENTRY_DELAY, code || this.masterCode);
254
+ this._sendPacket(packet);
255
+ }
256
+
257
+ /**
258
+ * Disarm partition
259
+ */
260
+ disarm(partition, code) {
261
+ if (!this._checkEstablished()) return;
262
+ const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
263
+ this._sendPacket(packet);
264
+ }
265
+
266
+ // ==================== Status Query Methods ====================
267
+
268
+ /**
269
+ * Send a 0x0800 command request and wait for the response.
270
+ * @param {number} expectedCmd - The inner command code we expect back (e.g., 0x0811)
271
+ * @param {Buffer} packet - The built packet to send
272
+ * @param {number} timeout - Timeout in ms (default 10000)
273
+ * @returns {Promise<object>} Parsed response data
274
+ */
275
+ _sendQuery(expectedCmd, packet, timeout = 10000) {
276
+ if (!this._checkEstablished()) {
277
+ return Promise.reject(new Error('Session not established'));
278
+ }
279
+
280
+ this._log(`[Query] Sending 0x${expectedCmd.toString(16)}`);
281
+
282
+ return new Promise((resolve, reject) => {
283
+ const timer = setTimeout(() => {
284
+ this._pendingRequests.delete(expectedCmd);
285
+ reject(new Error(`Query 0x${expectedCmd.toString(16)} timed out after ${timeout}ms`));
286
+ }, timeout);
287
+
288
+ this._pendingRequests.set(expectedCmd, { resolve, reject, timer });
289
+ this._sendPacket(packet);
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Resolve a pending request if one exists for this command.
295
+ * Called from response handlers.
296
+ * @returns {boolean} true if a pending request was resolved
297
+ */
298
+ _resolvePendingRequest(cmd, data) {
299
+ const pending = this._pendingRequests.get(cmd);
300
+ if (pending) {
301
+ clearTimeout(pending.timer);
302
+ this._pendingRequests.delete(cmd);
303
+ pending.resolve(data);
304
+ return true;
305
+ }
306
+ return false;
307
+ }
308
+
309
+ /**
310
+ * Query system capabilities (0x0613 wrapped in 0x0800)
311
+ * Returns max zones, partitions, users, etc.
312
+ * Neohub queries this FIRST on connect.
313
+ * @returns {Promise<object>} Capabilities object
314
+ */
315
+ queryCapabilities() {
316
+ const packet = this.session.buildCapabilitiesRequest();
317
+ return this._sendQuery(CMD.SYSTEM_CAPABILITIES, packet);
318
+ }
319
+
320
+ /**
321
+ * Query zone status (0x0811 wrapped in 0x0800)
322
+ * @param {number} startZone - Starting zone number (default 1)
323
+ * @param {number} numZones - Number of zones (uses cached capabilities if not specified)
324
+ * @returns {Promise<Array>} Array of zone status objects
325
+ */
326
+ queryZoneStatus(startZone = 1, numZones) {
327
+ const count = numZones || this._capabilities?.maxZones || 32;
328
+ const packet = this.session.buildZoneStatusRequest(startZone, count);
329
+ return this._sendQuery(CMD.ZONE_STATUS, packet);
330
+ }
331
+
332
+ /**
333
+ * Query partition status (0x0812 wrapped in 0x0800)
334
+ * @param {number} partition - Partition number (default 1)
335
+ * @returns {Promise<object>} Partition status object
336
+ */
337
+ queryPartitionStatus(partition = 1) {
338
+ const packet = this.session.buildPartitionStatusRequest(partition);
339
+ return this._sendQuery(CMD.PARTITION_STATUS, packet);
340
+ }
341
+
342
+ /**
343
+ * Query global system status (0x0810 wrapped in 0x0800)
344
+ * @returns {Promise<Buffer>} Raw global status data
345
+ */
346
+ queryGlobalStatus() {
347
+ const packet = this.session.buildGlobalStatusRequest();
348
+ return this._sendQuery(CMD.GLOBAL_STATUS, packet);
349
+ }
350
+
351
+ /**
352
+ * Query system trouble status (0x0822 wrapped in 0x0800)
353
+ * @returns {Promise<Array>} Array of trouble objects
354
+ */
355
+ queryTroubleStatus() {
356
+ const packet = this.session.buildTroubleStatusRequest();
357
+ return this._sendQuery(CMD.SYSTEM_TROUBLE_STATUS, packet);
358
+ }
359
+
360
+ /**
361
+ * Query zone bypass status (0x0813 wrapped in 0x0800)
362
+ * @param {number} zone - Zone number (0 = all zones)
363
+ * @param {number} numZones - Number of zones (default 128)
364
+ * @returns {Promise<object>} Bypass status data
365
+ */
366
+ queryZoneBypassStatus(zone = 0, numZones = 128) {
367
+ const packet = this.session.buildZoneBypassStatusRequest(zone, numZones);
368
+ return this._sendQuery(CMD.ZONE_BYPASS_STATUS, packet);
369
+ }
370
+
371
+ /**
372
+ * Send a raw command directly (not wrapped in 0x0800)
373
+ * @param {number} command - Command code (e.g., 0x0812)
374
+ * @param {Buffer} [payload] - Optional payload data
375
+ */
376
+ sendDirect(command, payload = null) {
377
+ if (!this._checkEstablished()) return;
378
+ const packet = this.session.buildCommand(command, payload);
379
+ this._logMinimal(`[Direct] Sending command 0x${command.toString(16).padStart(4, '0')} ${payload ? 'with ' + payload.length + ' bytes payload' : 'no payload'}`);
380
+ this._sendPacket(packet);
381
+ }
382
+
383
+ // ==================== Authentication Methods ====================
384
+
385
+ /**
386
+ * Authenticate with access code - simple format (0x0400)
387
+ * Uses 6-digit ASCII format matching interactive CLI
388
+ * @param {string} code - User/master code (e.g., "5555")
389
+ */
390
+ authenticate(code) {
391
+ if (!this._checkEstablished()) return;
392
+ const codeStr = (code || this.masterCode).toString().padStart(6, '0').slice(0, 6);
393
+ const payload = Buffer.from(codeStr, 'ascii');
394
+ const packet = this.session.buildCommand(0x0400, payload);
395
+ this._sendPacket(packet);
396
+ }
397
+
398
+ /**
399
+ * Authenticate with access code - extended format (0x0400)
400
+ * Includes partition and access level metadata
401
+ * @param {number} partition - Partition number (1-8, or 0 for all)
402
+ * @param {string} code - User/master code (e.g., "5555")
403
+ * @param {number} accessLevel - 0=User, 1=Installer, 2=Master
404
+ */
405
+ authenticateExtended(partition = 1, code, accessLevel = 0) {
406
+ if (!this._checkEstablished()) return;
407
+ const packet = this.session.buildAccessLevelEnter(
408
+ code || this.masterCode,
409
+ partition,
410
+ accessLevel,
411
+ 'bcd'
412
+ );
413
+ this._sendPacket(packet);
414
+ }
415
+
416
+ /**
417
+ * Exit authenticated access level (0x0401)
418
+ * @param {number} partition - Partition number
419
+ */
420
+ deauthenticate(partition = 1) {
421
+ if (!this._checkEstablished()) return;
422
+ const packet = this.session.buildAccessLevelExit(partition);
423
+ this._sendPacket(packet);
424
+ }
425
+
426
+ // ==================== State Accessors ====================
427
+
428
+ /**
429
+ * Get current zone states
430
+ */
431
+ getZones() {
432
+ return this.eventHandler.getAllZones();
433
+ }
434
+
435
+ /**
436
+ * Get current partition states
437
+ */
438
+ getPartitions() {
439
+ return this.eventHandler.getAllPartitions();
440
+ }
441
+
442
+ // ==================== Internal Methods ====================
443
+
444
+ _checkEstablished() {
445
+ if (this.handshakeState !== 'ESTABLISHED') {
446
+ this.emit('error', new Error('Session not established'));
447
+ return false;
448
+ }
449
+ return true;
450
+ }
451
+
452
+ _sendPacket(packet) {
453
+ if (!this.tcpSocket || this.tcpSocket.destroyed) {
454
+ this._logMinimal('[Error] No panel connection');
455
+ return;
456
+ }
457
+ this._log(`[TCP] TX ${packet.length} bytes`);
458
+ this._hexDump(packet);
459
+ this.tcpSocket.write(packet);
460
+ }
461
+
462
+ _handlePacket(data) {
463
+ try {
464
+ if (!this.session) {
465
+ const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {};
466
+ this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
467
+ }
468
+
469
+ const parsed = this.session.parsePacket(data);
470
+ if (!parsed) {
471
+ this._log('[Error] Failed to parse packet');
472
+ return;
473
+ }
474
+
475
+ this._log(`[TCP] RX ${data.length}B cmd=${CMD_NAMES[parsed.command] || parsed.command}`);
476
+ this._hexDump(data);
477
+ this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
478
+ this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
479
+ this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
480
+ if (parsed.appSequence !== null) {
481
+ this._log(`[Packet] App Seq: ${parsed.appSequence}`);
482
+ }
483
+ if (parsed.commandData) {
484
+ this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
485
+ }
486
+
487
+ // Route based on command
488
+ this._routePacket(parsed);
489
+
490
+ } catch (err) {
491
+ // During WAITING state, parse errors are expected (stale encrypted packets from previous session)
492
+ if (this.handshakeState === 'WAITING') {
493
+ this._log(`[Recovery] Ignoring malformed packet while waiting for fresh handshake: ${err.message}`);
494
+ // Reset session to ensure we start fresh on next valid packet
495
+ if (this.session) {
496
+ this.session.disableAes();
497
+ this.session = null;
498
+ }
499
+ return;
500
+ }
501
+
502
+ this._log(`[Error] ${err.message}`);
503
+ this.emit('error', err);
504
+
505
+ // Reset on parse errors during handshake
506
+ if (this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
507
+ this._log('[Recovery] Parse error, resetting to wait for panel restart');
508
+ if (this.session) {
509
+ this.session.disableAes();
510
+ this.session = null;
511
+ }
512
+ this.handshakeState = 'WAITING';
513
+ }
514
+ }
515
+ }
516
+
517
+ _routePacket(parsed, skipAck = false) {
518
+ this._skipAck = skipAck;
519
+ const cmd = parsed.command;
520
+
521
+ // Handle ACKs
522
+ if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
523
+ this._handleSimpleAck(parsed);
524
+ return;
525
+ }
526
+
527
+ // Route based on command type
528
+ switch (cmd) {
529
+ case CMD.OPEN_SESSION:
530
+ this._handleOpenSession(parsed);
531
+ break;
532
+ case CMD.REQUEST_ACCESS:
533
+ this._handleRequestAccess(parsed);
534
+ break;
535
+ case CMD.COMMAND_RESPONSE:
536
+ this._handleCommandResponse(parsed);
537
+ break;
538
+ case CMD.COMMAND_ERROR:
539
+ this._handleCommandError(parsed);
540
+ break;
541
+ case CMD.POLL:
542
+ this._handlePoll(parsed);
543
+ break;
544
+ case CMD.LIFESTYLE_ZONE_STATUS:
545
+ this._handleLifestyleZoneStatus(parsed);
546
+ break;
547
+ case CMD.TIME_DATE_BROADCAST:
548
+ this._handleTimeDateBroadcast(parsed);
549
+ break;
550
+ case CMD.ZONE_STATUS:
551
+ this._handleZoneStatusResponse(parsed);
552
+ break;
553
+ case CMD.PARTITION_STATUS:
554
+ this._handlePartitionStatusResponse(parsed);
555
+ break;
556
+ case CMD.GLOBAL_STATUS:
557
+ this._handleGlobalStatusResponse(parsed);
558
+ break;
559
+ case CMD.SYSTEM_CAPABILITIES:
560
+ this._handleCapabilitiesResponse(parsed);
561
+ break;
562
+ case CMD.SYSTEM_TROUBLE_STATUS:
563
+ case CMD.SYSTEM_TROUBLE_STATUS_OLD:
564
+ this._handleTroubleStatusResponse(parsed);
565
+ break;
566
+ case CMD.TROUBLE_DETAIL:
567
+ this._handleTroubleDetail(parsed);
568
+ break;
569
+ case CMD.NOTIFICATION_ARMING:
570
+ this._handleArmingNotification(parsed);
571
+ break;
572
+ case CMD.NOTIFICATION_PARTITION_READY:
573
+ this._handlePartitionReadyNotification(parsed);
574
+ break;
575
+ case CMD.NOTIFICATION_PARTITION_TROUBLE:
576
+ this._handlePartitionTroubleNotification(parsed);
577
+ break;
578
+ case CMD.MULTIPLE_MESSAGE:
579
+ this._handleMultipleMessagePacket(parsed);
580
+ break;
581
+ default:
582
+ if (this.handshakeState === 'ESTABLISHED') {
583
+ const cmdHex = cmd ? '0x' + cmd.toString(16).padStart(4, '0') : 'null';
584
+ const cmdName = CMD_NAMES[cmd] || 'UNKNOWN';
585
+ this._logMinimal(`[Session] Received command ${cmdName} (${cmdHex})`);
586
+ if (parsed.commandData && parsed.commandData.length > 0) {
587
+ this._logMinimal(`[Session] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex')}`);
588
+ }
589
+ this._ack();
590
+ }
591
+ break;
592
+ }
593
+ }
594
+
595
+ // ==================== Handshake Handlers ====================
596
+
597
+ _handleOpenSession(parsed) {
598
+ const data = parsed.commandData;
599
+ if (!data || data.length < 14) {
600
+ this._log('[Error] Invalid OPEN_SESSION data');
601
+ return;
602
+ }
603
+
604
+ const deviceType = data[0];
605
+ const encryptionType = data[13];
606
+
607
+ this._log(`[Session] OPEN_SESSION received:`);
608
+ this._log(` Device Type: ${deviceType}`);
609
+ this._log(` Encryption Type: ${encryptionType}`);
610
+
611
+ // Emit connecting event now that we have a valid OPEN_SESSION
612
+ this.emit('session:connecting');
613
+ this._logMinimal(`[Session] Panel connecting from ${this.panelAddress}`);
614
+ this._logMinimal('[Handshake] Starting session establishment...');
615
+
616
+ // Handle panel restart mid-handshake
617
+ if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
618
+ this._log(`[Session] Panel restarting handshake (was in state ${this.handshakeState}), resetting session`);
619
+ const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {};
620
+ this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
621
+ }
622
+
623
+ // Save panel's OpenSession payload for echoing back
624
+ this._panelOpenSessionData = data;
625
+
626
+ // Send COMMAND_RESPONSE echoing the command's appSequence (NOT deviceType)
627
+ // OpenSession is a CommandMessageBase - appSequence is the CommandSequence
628
+ const cmdSeq = parsed.appSequence;
629
+ this._log(`[Handshake] Sending COMMAND_RESPONSE (echoing cmdSeq=${cmdSeq})`);
630
+ const response = this.session.buildCommandResponseWithAppSeq(cmdSeq, 0x00);
631
+ this._sendPacket(response);
632
+ this.handshakeState = 'SENT_CMD_RESPONSE_1';
633
+ }
634
+
635
+ _handleSimpleAck(parsed) {
636
+ this._log(`[Handshake] Got ACK ${parsed.receiverSequence}, state=${this.handshakeState}`);
637
+
638
+ if (this.handshakeState === 'SENT_CMD_RESPONSE_1') {
639
+ // Panel ACKed our first COMMAND_RESPONSE, send our OPEN_SESSION
640
+ // Echo back the panel's OpenSession data (neohub echoes the same message)
641
+ this._log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
642
+ const payload = this._panelOpenSessionData || Buffer.from([
643
+ 0x01, 0x80, 0x00, 0x00, 0x02, 0x01, 0x02, 0x41,
644
+ 0x02, 0x00, 0x02, 0x00, 0x00, 0x01, 0x01
645
+ ]);
646
+ const packet = this.session.buildOpenSessionResponse(payload);
647
+ this._sendPacket(packet);
648
+ this.handshakeState = 'SENT_OPEN_SESSION';
649
+ } else if (this.handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
650
+ // Panel ACKed our encrypted COMMAND_RESPONSE to their REQUEST_ACCESS
651
+ // Now send our REQUEST_ACCESS (also encrypted, send key already enabled)
652
+ this._log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
653
+
654
+ let reqAccessPacket;
655
+ if (this.detectedEncryptionType === 2) {
656
+ this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
657
+ reqAccessPacket = this.session.buildRequestAccessType2();
658
+ } else {
659
+ this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
660
+ reqAccessPacket = this.session.buildRequestAccessType1();
661
+ }
662
+
663
+ this._sendPacket(reqAccessPacket);
664
+
665
+ // Enable receive encryption
666
+ this.session.receiveAesActive = true;
667
+ this.session.receiveAesKey = this.session.pendingReceiveKey;
668
+ this._log(`[Handshake] Enabled receive encryption`);
669
+
670
+ this.handshakeState = 'SENT_REQUEST_ACCESS';
671
+ } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
672
+ // Panel ACKed our REQUEST_ACCESS - session established!
673
+ this._onSessionEstablished();
674
+ }
675
+ }
676
+
677
+ _onSessionEstablished() {
678
+ this.handshakeState = 'ESTABLISHED';
679
+ this._logMinimal(`[Handshake] Session established (Type ${this.detectedEncryptionType} encryption)`);
680
+ this._log(`[Handshake] *** SESSION ESTABLISHED ***`);
681
+ this._log(`[Handshake] Encryption Type: ${this.detectedEncryptionType}`);
682
+ this._log(`[Handshake] SEND key: ${this.session.derivedSendKey?.toString('hex')}`);
683
+ this._log(`[Handshake] RECV key: ${this.session.pendingReceiveKey?.toString('hex')}`);
684
+
685
+ this.emit('session:established', {
686
+ encryptionType: this.detectedEncryptionType,
687
+ sendKey: this.session.derivedSendKey?.toString('hex'),
688
+ recvKey: this.session.pendingReceiveKey?.toString('hex')
689
+ });
690
+
691
+ // Start heartbeat timer (neohub sends ConnectionPoll every 100 seconds)
692
+ this._startHeartbeat();
693
+
694
+ // Start session readiness timer (neohub discards notifications for first 2s)
695
+ this._sessionReady = false;
696
+ this._resetReadyTimer();
697
+ }
698
+
699
+ _startHeartbeat() {
700
+ if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
701
+ this._heartbeatInterval = setInterval(() => {
702
+ if (this.handshakeState === 'ESTABLISHED' && this.session) {
703
+ this._log('[Heartbeat] Sending POLL');
704
+ const packet = this.session.buildPoll();
705
+ this._sendPacket(packet);
706
+ }
707
+ }, 100_000); // 100 seconds, matching neohub
708
+ }
709
+
710
+ _resetReadyTimer() {
711
+ if (this._readyTimer) clearTimeout(this._readyTimer);
712
+ this._readyTimer = setTimeout(() => {
713
+ if (!this._sessionReady) {
714
+ this._sessionReady = true;
715
+ this._logMinimal('[Session] Ready - initial message burst complete');
716
+ this.emit('session:ready');
717
+ // Pull initial status like neohub's PanelConfigurationHandler
718
+ this._pullInitialStatus();
719
+ }
720
+ }, 2000); // 2 seconds of silence, matching neohub
721
+ }
722
+
723
+ async _pullInitialStatus() {
724
+ this._logMinimal('[Status] Pulling initial status (neohub sequence)...');
725
+
726
+ let caps = null;
727
+ let zones = [];
728
+ let partStatus = null;
729
+
730
+ // Step 1: Query capabilities first (neohub does this first)
731
+ try {
732
+ caps = await this.queryCapabilities();
733
+ this._logMinimal(`[Status] Capabilities: ${caps.maxZones} zones, ${caps.maxPartitions} partitions`);
734
+ } catch (e) {
735
+ this._logMinimal(`[Status] Capabilities query failed: ${e.message}`);
736
+ }
737
+
738
+ const maxZones = caps?.maxZones || 128;
739
+ const maxPartitions = caps?.maxPartitions || 8;
740
+
741
+ // Step 2: Query partition status
742
+ try {
743
+ partStatus = await this.queryPartitionStatus(1);
744
+ if (partStatus) {
745
+ const state = partStatus.awayArmed ? 'AWAY' :
746
+ partStatus.stayArmed ? 'STAY' :
747
+ partStatus.nightArmed ? 'NIGHT' :
748
+ partStatus.armed ? 'ARMED' : 'DISARMED';
749
+ this._logMinimal(`[Status] Partition 1: ${state}`);
750
+ }
751
+ } catch (e) {
752
+ this._logMinimal(`[Status] Partition query failed: ${e.message}`);
753
+ }
754
+
755
+ // Step 3: Query zone status
756
+ try {
757
+ zones = await this.queryZoneStatus(1, maxZones);
758
+ const openZones = zones.filter(z => z.open);
759
+ this._logMinimal(`[Status] ${zones.length} zones (${openZones.length} open)`);
760
+ } catch (e) {
761
+ this._logMinimal(`[Status] Zone query failed: ${e.message}`);
762
+ }
763
+
764
+ this.emit('status:ready', { zones, partition: partStatus, capabilities: caps });
765
+ }
766
+
767
+ _handleRequestAccess(parsed) {
768
+ const data = parsed.commandData;
769
+ if (!data || data.length < 17) {
770
+ this._log('[Error] Invalid REQUEST_ACCESS data');
771
+ return;
772
+ }
773
+
774
+ const initType = data[0];
775
+ const initializer = data.slice(1);
776
+
777
+ this._log(`[Handshake] Got panel's REQUEST_ACCESS`);
778
+ this._log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
779
+
780
+ // Determine encryption type based on initializer length
781
+ if (initializer.length === 48) {
782
+ // Type 1 encryption
783
+ this.detectedEncryptionType = 1;
784
+ this._log(`[Handshake] Encryption Type 1 (48-byte initializer)`);
785
+
786
+ const sendKey = parseType1Initializer(this.accessCode, initializer, this.integrationId, this.logLevel === 'verbose');
787
+ this._logMinimal('[Handshake] Type 1 encryption negotiated');
788
+ this._log(`[Handshake] Type 1 SEND key derived`);
789
+ this.session.derivedSendKey = sendKey;
790
+
791
+ } else if (initializer.length === 16) {
792
+ // Type 2 encryption
793
+ this.detectedEncryptionType = 2;
794
+ this._log(`[Handshake] Encryption Type 2 (16-byte initializer)`);
795
+
796
+ // Type 2: Symmetric transform with 32-hex access code
797
+ let accessCode32 = this.accessCode;
798
+ if (accessCode32.length === 8) {
799
+ accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
800
+ }
801
+
802
+ const sendKey = type2InitializerTransform(accessCode32, initializer);
803
+ this._logMinimal('[Handshake] Type 2 encryption negotiated');
804
+ this._log(`[Handshake] Type 2 SEND key derived`);
805
+ this.session.derivedSendKey = sendKey;
806
+
807
+ } else {
808
+ this._log(`[Error] Unknown initializer length: ${initializer.length}`);
809
+ return;
810
+ }
811
+
812
+ // Enable outbound encryption BEFORE sending COMMAND_RESPONSE
813
+ // (neohub: "Configure outbound encryption IMMEDIATELY — step 8 is encrypted")
814
+ this.session.sendAesActive = true;
815
+ this.session.sendAesKey = this.session.derivedSendKey;
816
+ this._log(`[Handshake] Enabled outbound encryption`);
817
+
818
+ // Send ENCRYPTED COMMAND_RESPONSE (first encrypted message!)
819
+ const appSeq = parsed.appSequence;
820
+ this._log(`[Handshake] Sending ENCRYPTED COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
821
+ const response = this.session.buildCommandResponseWithAppSeq(appSeq, 0x00);
822
+ this._sendPacket(response);
823
+
824
+ // Wait for panel's ACK before sending our REQUEST_ACCESS
825
+ // (neohub waits for SimpleAck at step 9 before step 10)
826
+ this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
827
+ }
828
+
829
+ _handleCommandResponse(parsed) {
830
+ const data = parsed.commandData;
831
+ const responseCode = data?.[0] || 0;
832
+ const appSeqAsEcho = parsed.appSequence;
833
+
834
+ this._log(`[Session] Got COMMAND_RESPONSE (app_seq: ${appSeqAsEcho}, response_code: ${responseCode})`);
835
+
836
+ // COMMAND_RESPONSE is an intermediate ack - NOT a rejection.
837
+ // Neohub's CommandRequestReceiver treats it as an ack and waits for the
838
+ // actual data message. Only COMMAND_ERROR rejects pending requests.
839
+ // Response codes: 0=ack, 1=success, 2+=informational (panel may still send data)
840
+ if (this.handshakeState === 'ESTABLISHED') {
841
+ this.emit('command:response', { responseCode, appSequence: appSeqAsEcho, data });
842
+ }
843
+
844
+ const ack = this.session.buildSimpleAck();
845
+ this._sendPacket(ack);
846
+
847
+ if (this.handshakeState === 'SENT_OPEN_SESSION') {
848
+ // Panel accepted our OPEN_SESSION
849
+ this.handshakeState = 'WAITING_REQUEST_ACCESS';
850
+ } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
851
+ // Panel accepted our REQUEST_ACCESS - session established!
852
+ this._onSessionEstablished();
853
+ }
854
+ }
855
+
856
+ _handleCommandError(parsed) {
857
+ // CommandError (IMessageData, NOT CommandMessageBase):
858
+ // [Command 2B BE][NackCode 1B]
859
+ // parseHeader puts first byte into appSequence
860
+ const fullPayload = this._reconstructPayload(parsed);
861
+ const failedCmd = fullPayload.length >= 2 ? fullPayload.readUInt16BE(0) : 0;
862
+ const nackCode = fullPayload.length >= 3 ? fullPayload[2] : 0;
863
+ const nackNames = {
864
+ 1: 'InvalidCommandLength', 2: 'InvalidCommand', 3: 'InvalidSequence',
865
+ 4: 'PanelNotResponding', 17: 'InvalidAccessCode', 18: 'AccessCodeRequired',
866
+ 23: 'FunctionNotAvailable', 25: 'CommandTimeout',
867
+ };
868
+ const error = {
869
+ failedCommand: failedCmd,
870
+ nackCode,
871
+ message: `${CMD_NAMES[failedCmd] || '0x' + failedCmd.toString(16)}: ${nackNames[nackCode] || 'NackCode ' + nackCode}`,
872
+ };
873
+
874
+ this._log(`[Command Error] ${error.message}`);
875
+ this.emit('command:error', error);
876
+
877
+ // Don't reject pending requests - panel often sends COMMAND_ERROR as an
878
+ // intermediate response, then sends actual data afterward.
879
+ // Timeout handles genuine failures.
880
+
881
+ this._ack();
882
+ }
883
+
884
+ _handlePoll(parsed) {
885
+ this._log(`[Session] Got POLL, sending ACK`);
886
+ if (!this._sessionReady) this._resetReadyTimer();
887
+ this._ack();
888
+ }
889
+
890
+ // ==================== Notification Handlers ====================
891
+
892
+ _handleLifestyleZoneStatus(parsed) {
893
+ // If we receive encrypted zone data, session is established (even if we missed the final ACK)
894
+ if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
895
+ this._onSessionEstablished();
896
+ }
897
+
898
+ // Reset ready timer on each inbound message (matching neohub)
899
+ if (!this._sessionReady) this._resetReadyTimer();
900
+
901
+ // Reconstruct full payload: for non-CommandMessageBase messages,
902
+ // parseHeader puts the first payload byte into appSequence
903
+ // NotificationLifestyleZoneStatus: [CompactInt:ZoneNumber][Status:1B]
904
+ const fullPayload = this._reconstructPayload(parsed);
905
+
906
+ if (fullPayload && fullPayload.length >= 3) {
907
+ // Parse CompactInteger zone number
908
+ const zone = ITv2Session.decodeVarBytes(fullPayload, 0);
909
+ const zoneNum = zone.value;
910
+ const statusValue = fullPayload[zone.bytesRead];
911
+
912
+ // Update internal state and get full parsed status
913
+ const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
914
+
915
+ // Emit events with full status object
916
+ this.emit('zone:status', zoneNum, fullStatus);
917
+
918
+ if (fullStatus.open) {
919
+ this.emit('zone:open', zoneNum);
920
+ this._log(`[Zone ${zoneNum}] OPEN (status: ${statusValue})`);
921
+ } else {
922
+ this.emit('zone:closed', zoneNum);
923
+ this._log(`[Zone ${zoneNum}] CLOSED (status: ${statusValue})`);
924
+ }
925
+ }
926
+
927
+ this._ack();
928
+ }
929
+
930
+ _handleTimeDateBroadcast(parsed) {
931
+ if (!this._sessionReady) this._resetReadyTimer();
932
+ const fullPayload = this._reconstructPayload(parsed);
933
+ if (fullPayload && fullPayload.length >= 5) {
934
+ this._log(`[Time/Date] Received broadcast`);
935
+ this.emit('notification:timeDate', fullPayload);
936
+ }
937
+ this._ack();
938
+ }
939
+
940
+ // ==================== Status Query Response Handlers ====================
941
+
942
+ _handleZoneStatusResponse(parsed) {
943
+ // ModuleZoneStatus (neohub format):
944
+ // [CompactInt:ZoneStart][CompactInt:ZoneCount][StatusSizeInBytes:1B][ZoneStatusBytes...]
945
+ const fullPayload = this._reconstructPayload(parsed);
946
+ this._logMinimal(`[Zone Status] Received zone status response`);
947
+ this._log(`[Zone Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
948
+
949
+ const zones = [];
950
+
951
+ if (fullPayload && fullPayload.length >= 4) {
952
+ try {
953
+ let offset = 0;
954
+ const zoneStart = ITv2Session.decodeVarBytes(fullPayload, offset);
955
+ offset += zoneStart.bytesRead;
956
+
957
+ const zoneCount = ITv2Session.decodeVarBytes(fullPayload, offset);
958
+ offset += zoneCount.bytesRead;
959
+
960
+ const statusSize = fullPayload[offset++]; // typically 1
961
+
962
+ this._logMinimal(`[Zone Status] Zones ${zoneStart.value}-${zoneStart.value + zoneCount.value - 1} (${zoneCount.value} zones, ${statusSize}B each)`);
963
+
964
+ for (let i = 0; i < zoneCount.value && offset < fullPayload.length; i++) {
965
+ const zoneNum = zoneStart.value + i;
966
+ const statusByte = fullPayload[offset];
967
+ offset += statusSize;
968
+
969
+ const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusByte);
970
+ zones.push(fullStatus);
971
+ this.emit('zone:statusResponse', zoneNum, fullStatus);
972
+ this.emit('zone:status', zoneNum, fullStatus);
973
+ }
974
+ } catch (e) {
975
+ this._log(`[Zone Status] Parse error: ${e.message}`);
976
+ }
977
+ }
978
+
979
+ this._resolvePendingRequest(CMD.ZONE_STATUS, zones);
980
+ this._ack();
981
+ }
982
+
983
+ _handlePartitionStatusResponse(parsed) {
984
+ // ModulePartitionStatus (neohub format):
985
+ // [CompactInt:Partition][LeadingLengthArray:PartitionStatus]
986
+ // PartitionStatus bytes:
987
+ // Byte 0 (Status1): Armed=0x01, StayArmed=0x03, ArmedAway=0x05, NightArmed=0x09,
988
+ // ExitDelay=0x20, EntryDelay=0x40, QuickExit=0x80
989
+ // Byte 1 (Status2): Alarm=0x01, Trouble=0x02, Bypass=0x04, Programming=0x08,
990
+ // AlarmMemory=0x10, DoorChime=0x20, Bell=0x40, Buzzer=0x80
991
+ // Byte 2 (Status3): FirePreAlert=0x01
992
+ const fullPayload = this._reconstructPayload(parsed);
993
+ this._logMinimal(`[Partition Status] Received partition status response`);
994
+ this._log(`[Partition Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
995
+
996
+ if (fullPayload && fullPayload.length >= 3) {
997
+ try {
998
+ let offset = 0;
999
+ const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
1000
+ offset += partition.bytesRead;
1001
+
1002
+ // LeadingLengthArray: 1-byte length prefix followed by status bytes
1003
+ const statusLength = fullPayload[offset++];
1004
+ const statusBytes = fullPayload.slice(offset, offset + statusLength);
1005
+
1006
+ const partitionNum = partition.value;
1007
+ this._logMinimal(`[Partition Status] Partition ${partitionNum}: status bytes ${statusBytes.toString('hex')}`);
1008
+
1009
+ // Parse status flags from neohub's enum definitions
1010
+ const status1 = statusBytes.length > 0 ? statusBytes[0] : 0;
1011
+ const status2 = statusBytes.length > 1 ? statusBytes[1] : 0;
1012
+ const status3 = statusBytes.length > 2 ? statusBytes[2] : 0;
1013
+
1014
+ const partStatus = {
1015
+ partition: partitionNum,
1016
+ // Status1 flags
1017
+ armed: !!(status1 & 0x01),
1018
+ stayArmed: (status1 & 0x0F) === 0x03,
1019
+ awayArmed: (status1 & 0x0F) === 0x05,
1020
+ nightArmed: (status1 & 0x0F) === 0x09,
1021
+ noEntryDelay: (status1 & 0x1F) === 0x11,
1022
+ exitDelay: !!(status1 & 0x20),
1023
+ entryDelay: !!(status1 & 0x40),
1024
+ quickExit: !!(status1 & 0x80),
1025
+ // Status2 flags
1026
+ alarm: !!(status2 & 0x01),
1027
+ trouble: !!(status2 & 0x02),
1028
+ zonesBypassed: !!(status2 & 0x04),
1029
+ programmingMode: !!(status2 & 0x08),
1030
+ alarmInMemory: !!(status2 & 0x10),
1031
+ doorChime: !!(status2 & 0x20),
1032
+ audibleBell: !!(status2 & 0x40),
1033
+ buzzer: !!(status2 & 0x80),
1034
+ // Status3 flags
1035
+ firePreAlert: !!(status3 & 0x01),
1036
+ rawBytes: statusBytes.toString('hex'),
1037
+ };
1038
+
1039
+ // Update event handler
1040
+ const armedState = partStatus.awayArmed ? 0x02 :
1041
+ partStatus.stayArmed ? 0x01 :
1042
+ partStatus.nightArmed ? 0x03 :
1043
+ partStatus.armed ? 0x01 : 0x00;
1044
+ this.eventHandler.handlePartitionArming(partitionNum, armedState);
1045
+
1046
+ this.emit('partition:statusResponse', partitionNum, partStatus);
1047
+ this._resolvePendingRequest(CMD.PARTITION_STATUS, partStatus);
1048
+ } catch (e) {
1049
+ this._log(`[Partition Status] Parse error: ${e.message}`);
1050
+ }
1051
+ } else {
1052
+ this._log(`[Partition Status] Empty or invalid response`);
1053
+ }
1054
+
1055
+ this._ack();
1056
+ }
1057
+
1058
+ _handleCapabilitiesResponse(parsed) {
1059
+ const fullPayload = this._reconstructPayload(parsed);
1060
+ this._logMinimal(`[Capabilities] Received system capabilities`);
1061
+
1062
+ const caps = {};
1063
+ if (fullPayload && fullPayload.length >= 2) {
1064
+ try {
1065
+ let offset = 0;
1066
+ const fields = ['maxZones', 'maxUsers', 'maxPartitions', 'maxFOBs', 'maxProxTags', 'maxOutputs'];
1067
+ for (const field of fields) {
1068
+ if (offset >= fullPayload.length) break;
1069
+ const val = ITv2Session.decodeVarBytes(fullPayload, offset);
1070
+ caps[field] = val.value;
1071
+ offset += val.bytesRead;
1072
+ }
1073
+ this._logMinimal(`[Capabilities] ${caps.maxZones} zones, ${caps.maxPartitions} partitions, ${caps.maxUsers} users`);
1074
+ } catch (e) {
1075
+ this._log(`[Capabilities] Parse error: ${e.message}`);
1076
+ }
1077
+ }
1078
+
1079
+ this._capabilities = caps;
1080
+ this._resolvePendingRequest(CMD.SYSTEM_CAPABILITIES, caps);
1081
+ this._ack();
1082
+ }
1083
+
1084
+ _handleGlobalStatusResponse(parsed) {
1085
+ const fullPayload = this._reconstructPayload(parsed);
1086
+ this._logMinimal(`[Global Status] Received global status response`);
1087
+ this._log(`[Global Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
1088
+
1089
+ if (fullPayload && fullPayload.length > 0) {
1090
+ this.emit('global:statusResponse', fullPayload);
1091
+ }
1092
+
1093
+ this._resolvePendingRequest(CMD.GLOBAL_STATUS, fullPayload);
1094
+ this._ack();
1095
+ }
1096
+
1097
+ _handleTroubleStatusResponse(parsed) {
1098
+ const fullPayload = this._reconstructPayload(parsed);
1099
+ this._logMinimal(`[Trouble Status] Received trouble status response`);
1100
+ this._log(`[Trouble Status] Raw data (${fullPayload?.length || 0} bytes): ${fullPayload?.toString('hex') || 'none'}`);
1101
+
1102
+ if (!fullPayload || fullPayload.length === 0) {
1103
+ this._logMinimal(`[Trouble Status] No active troubles`);
1104
+ this.emit('trouble:statusResponse', []);
1105
+ this._resolvePendingRequest(CMD.SYSTEM_TROUBLE_STATUS, []);
1106
+ this._ack();
1107
+ return;
1108
+ }
1109
+
1110
+ const troubles = [];
1111
+ let offset = 0;
1112
+
1113
+ try {
1114
+ while (offset < fullPayload.length) {
1115
+ const device = ITv2Session.decodeVarBytes(fullPayload, offset);
1116
+ offset += device.bytesRead;
1117
+
1118
+ const troubleCount = fullPayload[offset];
1119
+ offset += 1;
1120
+
1121
+ const troubleTypes = [];
1122
+ for (let i = 0; i < troubleCount; i++) {
1123
+ const trouble = ITv2Session.decodeVarBytes(fullPayload, offset);
1124
+ offset += trouble.bytesRead;
1125
+ troubleTypes.push(trouble.value);
1126
+ }
1127
+
1128
+ troubles.push({ deviceType: device.value, troubles: troubleTypes });
1129
+ this._logMinimal(`[Trouble Status] Device ${device.value}: troubles [${troubleTypes.join(', ')}]`);
1130
+ }
1131
+ } catch (e) {
1132
+ this._logMinimal(`[Trouble Status] Parse error at offset ${offset}: ${e.message}`);
1133
+ this._logMinimal(`[Trouble Status] Raw hex: ${fullPayload.toString('hex')}`);
1134
+ }
1135
+
1136
+ this.emit('trouble:statusResponse', troubles);
1137
+ this._resolvePendingRequest(CMD.SYSTEM_TROUBLE_STATUS, troubles);
1138
+ this._ack();
1139
+ }
1140
+
1141
+ _handleTroubleDetail(parsed) {
1142
+ // 0x0823 Trouble Detail Notification (from SDK DSC_ITroubleData interface)
1143
+ // Contains zero or more records, each:
1144
+ // [DeviceModuleType VarBytes][DeviceModuleNumber VarBytes][TroubleType VarBytes][TroubleState 1B]
1145
+ const TROUBLE_TYPES = {
1146
+ 0x01: 'No Troubles', 0x02: 'Device Tamper', 0x03: 'Device Fault',
1147
+ 0x04: 'Device Low Battery', 0x05: 'Device Delinquency', 0x06: 'Failure To Communicate',
1148
+ 0x07: 'GSIP Receiver Trouble', 0x08: 'Receiver Not Available', 0x09: 'Receiver Supervision',
1149
+ 0x0A: 'Device AC Trouble', 0x0B: 'Device Low Sensitivity', 0x0C: 'Device Internal Fault',
1150
+ 0x0D: 'RF Device Not Networked', 0x0E: 'Device Mask Trouble', 0x0F: 'Power Unit Failure',
1151
+ 0x10: 'Overcurrent', 0x21: 'System Troubles Level 1', 0x22: 'Service Request',
1152
+ 0x23: 'Communications Troubles', 0x31: 'Bell Trouble', 0x36: 'RF Jam',
1153
+ 0x37: 'Fire Trouble', 0x38: 'CO Trouble', 0x3A: 'Ground Fault',
1154
+ 0x3B: 'Output Fault', 0x3C: 'TLM Trouble', 0x40: 'Time/Date Trouble',
1155
+ 0x41: 'Configuration Trouble', 0x42: 'Warm Start', 0x43: 'USB/WIFI Trouble',
1156
+ 0x44: 'Critical Shutdown', 0x51: 'SIM Lock', 0x56: 'GSM Trouble',
1157
+ 0x61: 'Ethernet Trouble', 0x81: 'Module Supervisory', 0x82: 'Module Tamper',
1158
+ 0x85: 'Module AC Trouble', 0x87: 'Module Low Battery', 0x88: 'Module Battery Missing',
1159
+ 0x89: 'Module Battery Charger', 0x8B: 'Module Bus Low Voltage', 0x8C: 'Module Bus Fault',
1160
+ 0x8D: 'Module AUX Trouble', 0x8E: 'Firmware Upgrade Fault', 0x91: 'Gas Trouble',
1161
+ 0x92: 'Heat Trouble', 0x93: 'Freeze Trouble', 0x95: 'Smoke Trouble',
1162
+ 0x96: 'Radio SIM Failure', 0x97: 'Module Battery Disconnected',
1163
+ 0x98: 'Radio Low Signal', 0x99: 'GSM Network', 0x9A: 'IP Remote Server',
1164
+ };
1165
+ const DEVICE_TYPES = {
1166
+ 0x01: 'System', 0x02: 'Zone', 0x03: 'Keypad', 0x04: 'Siren',
1167
+ 0x05: 'FOB', 0x07: 'Repeater', 0x11: 'AML Module', 0x12: 'IO Expander',
1168
+ 0x13: 'Zone Expander', 0x14: 'Wireless Transceiver', 0x15: 'Power Supply',
1169
+ 0x1D: 'Alternate Communicator', 0xF1: 'All Modules', 0xF2: 'Interface Module',
1170
+ 0xF3: 'IP Communicator',
1171
+ };
1172
+ const TROUBLE_STATES = { 0x01: 'Restored', 0x02: 'Trouble', 0x03: 'In Memory' };
1173
+
1174
+ const fullPayload = this._reconstructPayload(parsed);
1175
+ const troubles = [];
1176
+
1177
+ if (fullPayload && fullPayload.length >= 4) {
1178
+ let offset = 0;
1179
+ try {
1180
+ while (offset < fullPayload.length) {
1181
+ const devType = ITv2Session.decodeVarBytes(fullPayload, offset);
1182
+ offset += devType.bytesRead;
1183
+ const devNum = ITv2Session.decodeVarBytes(fullPayload, offset);
1184
+ offset += devNum.bytesRead;
1185
+ const troubleType = ITv2Session.decodeVarBytes(fullPayload, offset);
1186
+ offset += troubleType.bytesRead;
1187
+ if (offset >= fullPayload.length) break;
1188
+ const troubleState = fullPayload[offset++];
1189
+
1190
+ troubles.push({
1191
+ deviceType: devType.value,
1192
+ deviceTypeName: DEVICE_TYPES[devType.value] || `Device 0x${devType.value.toString(16)}`,
1193
+ deviceNumber: devNum.value,
1194
+ troubleType: troubleType.value,
1195
+ troubleTypeName: TROUBLE_TYPES[troubleType.value] || `Trouble 0x${troubleType.value.toString(16)}`,
1196
+ troubleState,
1197
+ troubleStateName: TROUBLE_STATES[troubleState] || `State ${troubleState}`,
1198
+ });
1199
+ }
1200
+ } catch (e) {
1201
+ this._log(`[Trouble Detail] Parse error at offset ${offset}: ${e.message}`);
1202
+ }
1203
+ }
1204
+
1205
+ for (const t of troubles) {
1206
+ this._logMinimal(`[Trouble] ${t.deviceTypeName} #${t.deviceNumber}: ${t.troubleTypeName} (${t.troubleStateName})`);
1207
+ }
1208
+
1209
+ this.emit('trouble:detail', troubles);
1210
+ this._ack();
1211
+ }
1212
+
1213
+ // ==================== Multiple Message Packet (0x0623) ====================
1214
+
1215
+ _handleMultipleMessagePacket(parsed) {
1216
+ // Panel batches multiple notifications with 1-byte length prefixes.
1217
+ // Format: [len1][cmd1 2B][payload1...][len2][cmd2 2B][payload2...]...
1218
+ const fullPayload = this._reconstructPayload(parsed);
1219
+ this._sendPacket(this.session.buildSimpleAck());
1220
+
1221
+ if (!fullPayload || fullPayload.length < 3) return;
1222
+
1223
+ let offset = 0;
1224
+ while (offset < fullPayload.length) {
1225
+ const msgLen = fullPayload[offset++];
1226
+ if (msgLen === 0 || offset + msgLen > fullPayload.length) break;
1227
+
1228
+ const msgBytes = fullPayload.slice(offset, offset + msgLen);
1229
+ offset += msgLen;
1230
+
1231
+ if (msgBytes.length < 2) continue;
1232
+ const subCmd = msgBytes.readUInt16BE(0);
1233
+ const subPayload = msgBytes.slice(2);
1234
+
1235
+ this._log(`[MultiMsg] Sub-message: ${CMD_NAMES[subCmd] || '0x' + subCmd.toString(16)} (${subPayload.length}B)`);
1236
+
1237
+ // Build a fake parsed object for re-routing
1238
+ const subParsed = {
1239
+ command: subCmd,
1240
+ appSequence: subPayload.length > 0 ? subPayload[0] : null,
1241
+ commandData: subPayload.length > 1 ? subPayload.slice(1) : null,
1242
+ senderSequence: parsed.senderSequence,
1243
+ receiverSequence: parsed.receiverSequence,
1244
+ };
1245
+
1246
+ // Route the sub-message through existing handlers
1247
+ // (ACK already sent for the outer 0x0623 packet)
1248
+ this._routePacket(subParsed, true);
1249
+ }
1250
+ }
1251
+
1252
+ // ==================== Notification Handlers ====================
1253
+
1254
+ _handleArmingNotification(parsed) {
1255
+ // NotificationArmDisarm (neohub format, IMessageData NOT CommandMessageBase):
1256
+ // [CompactInt:Partition][ArmMode:1B][Method:1B][CompactInt:UserId]
1257
+ const fullPayload = this._reconstructPayload(parsed);
1258
+ if (!fullPayload || fullPayload.length < 4) {
1259
+ this._ack();
1260
+ return;
1261
+ }
1262
+
1263
+ try {
1264
+ let offset = 0;
1265
+ const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
1266
+ offset += partition.bytesRead;
1267
+ const armMode = fullPayload[offset++];
1268
+ const method = fullPayload[offset++];
1269
+ let userId = 0;
1270
+ if (offset < fullPayload.length) {
1271
+ const uid = ITv2Session.decodeVarBytes(fullPayload, offset);
1272
+ userId = uid.value;
1273
+ }
1274
+
1275
+ const modeNames = {
1276
+ 0: 'DISARMED', 1: 'STAY', 2: 'AWAY', 3: 'NO_ENTRY_DELAY',
1277
+ 4: 'NIGHT', 5: 'QUICK', 6: 'USER'
1278
+ };
1279
+ const modeName = modeNames[armMode] || `MODE_${armMode}`;
1280
+ this._logMinimal(`[Partition ${partition.value}] ${modeName} (method=${method}, user=${userId})`);
1281
+
1282
+ this.eventHandler.handlePartitionArming(partition.value, armMode);
1283
+ this.emit('partition:arming', {
1284
+ partition: partition.value,
1285
+ armMode,
1286
+ modeName,
1287
+ method,
1288
+ userId,
1289
+ });
1290
+ } catch (e) {
1291
+ this._log(`[Arming Notification] Parse error: ${e.message}`);
1292
+ }
1293
+
1294
+ this._ack();
1295
+ }
1296
+
1297
+ _handlePartitionReadyNotification(parsed) {
1298
+ // NotificationPartitionReadyStatus (neohub format):
1299
+ // [CompactInt:PartitionNumber][Status:1B]
1300
+ // Status: 0=Reserved, 1=ReadyToArm, 2=ReadyToForceArm, 3=NotReadyToArm
1301
+ const fullPayload = this._reconstructPayload(parsed);
1302
+ if (!fullPayload || fullPayload.length < 3) {
1303
+ this._ack();
1304
+ return;
1305
+ }
1306
+
1307
+ try {
1308
+ let offset = 0;
1309
+ const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
1310
+ offset += partition.bytesRead;
1311
+ const readyStatus = fullPayload[offset];
1312
+
1313
+ const statusNames = { 1: 'READY', 2: 'FORCE_ARM_READY', 3: 'NOT_READY' };
1314
+ const isReady = readyStatus === 1 || readyStatus === 2;
1315
+ this._logMinimal(`[Partition ${partition.value}] ${statusNames[readyStatus] || 'UNKNOWN'}`);
1316
+
1317
+ this.eventHandler.handlePartitionReady(partition.value, isReady);
1318
+ this.emit('partition:ready', { partition: partition.value, readyStatus, isReady });
1319
+ } catch (e) {
1320
+ this._log(`[Ready Notification] Parse error: ${e.message}`);
1321
+ }
1322
+
1323
+ this._ack();
1324
+ }
1325
+
1326
+ _handlePartitionTroubleNotification(parsed) {
1327
+ const fullPayload = this._reconstructPayload(parsed);
1328
+ if (!fullPayload || fullPayload.length < 3) {
1329
+ this._ack();
1330
+ return;
1331
+ }
1332
+
1333
+ try {
1334
+ let offset = 0;
1335
+ const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
1336
+ offset += partition.bytesRead;
1337
+ const troubleFlags = fullPayload[offset];
1338
+
1339
+ this._logMinimal(`[Partition ${partition.value}] Trouble: 0x${troubleFlags.toString(16)}`);
1340
+ this.eventHandler.handlePartitionTrouble(partition.value, troubleFlags);
1341
+ this.emit('partition:trouble', { partition: partition.value, troubleFlags });
1342
+ } catch (e) {
1343
+ this._log(`[Trouble Notification] Parse error: ${e.message}`);
1344
+ }
1345
+
1346
+ this._ack();
1347
+ }
1348
+
1349
+ // ==================== Payload Helpers ====================
1350
+
1351
+ _ack() {
1352
+ if (!this._skipAck) {
1353
+ this._sendPacket(this.session.buildSimpleAck());
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Reconstruct full payload for non-CommandMessageBase messages.
1359
+ * parseHeader always treats byte 4 as appSequence, but for non-command messages
1360
+ * (notifications, status responses), that byte is actually the first payload byte
1361
+ * (typically a CompactInteger length prefix). This method reassembles the full payload.
1362
+ */
1363
+ _reconstructPayload(parsed) {
1364
+ if (parsed.appSequence !== null && parsed.commandData) {
1365
+ return Buffer.concat([Buffer.from([parsed.appSequence]), parsed.commandData]);
1366
+ } else if (parsed.appSequence !== null) {
1367
+ return Buffer.from([parsed.appSequence]);
1368
+ }
1369
+ return parsed.commandData || Buffer.alloc(0);
1370
+ }
1371
+
1372
+ // ==================== Utility Methods ====================
1373
+
1374
+ _handleError(error) {
1375
+ this._log(`[Error] ${error.message}`);
1376
+ this.emit('error', error);
1377
+ }
1378
+
1379
+ _log(message) {
1380
+ if (this.logLevel === 'verbose') {
1381
+ const timestamp = new Date().toISOString();
1382
+ console.log(`dsc itv2: [${timestamp}] ${message}`);
1383
+ }
1384
+ }
1385
+
1386
+ _logMinimal(message) {
1387
+ if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
1388
+ console.log(`dsc itv2: ${message}`);
1389
+ }
1390
+ }
1391
+
1392
+ _hexDump(buffer) {
1393
+ if (this.logLevel !== 'verbose') return;
1394
+
1395
+ for (let i = 0; i < buffer.length; i += 16) {
1396
+ const chunk = buffer.slice(i, i + 16);
1397
+ const hex = Array.from(chunk)
1398
+ .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
1399
+ .join(' ');
1400
+ const ascii = Array.from(chunk)
1401
+ .map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
1402
+ .join('');
1403
+
1404
+ const offset = i.toString(16).padStart(4, '0').toUpperCase();
1405
+ console.log(`dsc itv2: ${offset} ${hex.padEnd(48)} |${ascii}|`);
1406
+ }
1407
+ }
1408
+ }