dsc-itv2-client 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Not affiliated with DSC or DSC Alarm in any way, shape or form. Use at your own
6
6
  ## Features
7
7
 
8
8
  ✅ **Event-Driven Architecture** - Subscribe to zone changes with EventEmitter
9
- ✅ **Full Protocol Implementation** - Complete ITV2 handshake with Type 1 encryption
9
+ ✅ **Full Protocol Implementation** - Complete ITV2 handshake with Type 1 and 2 encryption
10
10
  ✅ **Real-Time Notifications** - Automatic zone status updates from panel
11
11
  ✅ **Partition Control** - Arm and disarm partitions
12
12
  ✅ **Encrypted Communication** - AES-128-ECB with dynamic key exchange
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsc-itv2-client",
3
3
  "author": "fajitacat",
4
- "version": "1.0.7",
4
+ "version": "1.0.9",
5
5
  "description": "Reverse engineered DSC ITV2 Protocol Client Library for TL280R Communicator - Monitor and control DSC alarm panels",
6
6
  "main": "src/index.js",
7
7
  "type": "module",
package/src/ITV2Client.js CHANGED
@@ -3,500 +3,530 @@
3
3
  * Event-driven client for DSC alarm panels using the ITV2 protocol
4
4
  */
5
5
 
6
- import { EventEmitter } from 'events';
6
+ import {EventEmitter} from 'events';
7
7
  import dgram from 'dgram';
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';
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
12
 
13
13
  /**
14
14
  * ITV2Client - Main library class
15
15
  * Handles DSC panel communication and emits events for status updates
16
16
  */
17
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 || 3073;
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.server = null;
31
- this.session = null;
32
- this.eventHandler = new EventHandler();
33
- this.handshakeState = 'WAITING';
34
- this.panelAddress = null;
35
- this.panelPort = null;
36
- this.detectedEncryptionType = null; // Set during handshake
37
-
38
- // Bind methods
39
- this._handlePacket = this._handlePacket.bind(this);
40
- this._handleError = this._handleError.bind(this);
41
- }
42
-
43
- /**
44
- * Start the client and listen for panel connection
45
- */
46
- async start() {
47
- return new Promise((resolve, reject) => {
48
- this.server = dgram.createSocket('udp4');
49
-
50
- this.server.on('error', (err) => {
51
- this._log(`[Error] UDP server error: ${err.message}`);
52
- this.emit('error', err);
53
- reject(err);
54
- });
55
-
56
- this.server.on('message', (msg, rinfo) => {
57
- this.panelAddress = rinfo.address;
58
- this.panelPort = rinfo.port;
59
- this._handlePacket(msg);
60
- });
61
-
62
- this.server.on('listening', () => {
63
- const address = this.server.address();
64
- this._log(`[UDP] Server listening on ${address.address}:${address.port}`);
65
- this.emit('listening', address);
66
- resolve();
67
- });
68
-
69
- this.server.bind(this.port, '0.0.0.0');
70
- });
71
- }
72
-
73
- /**
74
- * Stop the client and close the session gracefully
75
- */
76
- async stop() {
77
- if (this.session && this.handshakeState === 'ESTABLISHED') {
78
- try {
79
- const endSessionPacket = this.session.buildEndSession();
80
- this._sendPacket(endSessionPacket);
81
- this._log('[Shutdown] END_SESSION sent to panel');
82
- } catch (err) {
83
- this._log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
84
- }
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 || 3073;
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.server = null;
31
+ this.session = null;
32
+ this.eventHandler = new EventHandler();
33
+ this.handshakeState = 'WAITING';
34
+ this.panelAddress = null;
35
+ this.panelPort = null;
36
+ this.detectedEncryptionType = null; // Set during handshake
37
+
38
+ // Bind methods
39
+ this._handlePacket = this._handlePacket.bind(this);
40
+ this._handleError = this._handleError.bind(this);
41
+ this._boundShutdown = null; // Track signal handler for cleanup
85
42
  }
86
43
 
87
- if (this.server) {
88
- await new Promise((resolve) => {
89
- this.server.close(() => {
90
- this._log('[Shutdown] UDP server closed');
91
- resolve();
44
+ /**
45
+ * Start the client and listen for panel connection
46
+ */
47
+ async start() {
48
+ return new Promise((resolve, reject) => {
49
+ this.server = dgram.createSocket('udp4');
50
+
51
+ this.server.on('error', (err) => {
52
+ this._log(`[Error] UDP server error: ${err.message}`);
53
+ this.emit('error', err);
54
+ reject(err);
55
+ });
56
+
57
+ this.server.on('message', (msg, rinfo) => {
58
+ this.panelAddress = rinfo.address;
59
+ this.panelPort = rinfo.port;
60
+ this._handlePacket(msg);
61
+ });
62
+
63
+ this.server.on('listening', () => {
64
+ const address = this.server.address();
65
+ this._log(`[UDP] Server listening on ${address.address}:${address.port}`);
66
+ this.emit('listening', address);
67
+ resolve();
68
+ });
69
+
70
+ this.server.bind(this.port, '0.0.0.0');
71
+
72
+ // Register signal handlers (only once)
73
+ if (!this._boundShutdown) {
74
+ this._boundShutdown = async () => {
75
+ await this.stop();
76
+ process.exit(0);
77
+ };
78
+ process.on('SIGINT', this._boundShutdown);
79
+ process.on('SIGTERM', this._boundShutdown);
80
+ }
92
81
  });
93
- });
94
82
  }
95
83
 
96
- this.emit('session:closed');
97
- }
98
-
99
- /**
100
- * Arm partition in stay mode
101
- */
102
- armStay(partition, code) {
103
- if (!this._checkEstablished()) return;
104
- const packet = this.session.buildPartitionArm(partition, 0, code || this.masterCode);
105
- this._sendPacket(packet);
106
- }
107
-
108
- /**
109
- * Arm partition in away mode
110
- */
111
- armAway(partition, code) {
112
- if (!this._checkEstablished()) return;
113
- const packet = this.session.buildPartitionArm(partition, 1, code || this.masterCode);
114
- this._sendPacket(packet);
115
- }
116
-
117
- /**
118
- * Disarm partition
119
- */
120
- disarm(partition, code) {
121
- if (!this._checkEstablished()) return;
122
- const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
123
- this._sendPacket(packet);
124
- }
125
-
126
- /**
127
- * Get current zone states
128
- */
129
- getZones() {
130
- return this.eventHandler.getZoneStates();
131
- }
132
-
133
- /**
134
- * Get current partition states
135
- */
136
- getPartitions() {
137
- return this.eventHandler.getPartitionStates();
138
- }
139
-
140
- // ==================== Internal Methods ====================
141
-
142
- _checkEstablished() {
143
- if (this.handshakeState !== 'ESTABLISHED') {
144
- this.emit('error', new Error('Session not established'));
145
- return false;
84
+ /**
85
+ * Stop the client and close the session gracefully
86
+ */
87
+ async stop() {
88
+ if (this.session && this.handshakeState === 'ESTABLISHED') {
89
+ try {
90
+ const endSessionPacket = this.session.buildEndSession();
91
+ this._sendPacket(endSessionPacket);
92
+ this._log('[Shutdown] END_SESSION sent to panel');
93
+ } catch (err) {
94
+ this._log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
95
+ }
96
+ }
97
+
98
+ if (this.server) {
99
+ await new Promise((resolve) => {
100
+ this.server.close(() => {
101
+ this._log('[Shutdown] UDP server closed');
102
+ resolve();
103
+ });
104
+ });
105
+ this.server = null;
106
+ }
107
+
108
+ // Clear session and encryption state to prevent stale keys on reconnect
109
+ if (this.session) {
110
+ this.session.disableAes();
111
+ this.session = null;
112
+ }
113
+ this.handshakeState = 'WAITING';
114
+ this.panelAddress = null;
115
+ this.panelPort = null;
116
+ this.detectedEncryptionType = null;
117
+
118
+ // Remove signal handlers to prevent stacking on restart
119
+ if (this._boundShutdown) {
120
+ process.removeListener('SIGINT', this._boundShutdown);
121
+ process.removeListener('SIGTERM', this._boundShutdown);
122
+ this._boundShutdown = null;
123
+ }
124
+
125
+ this.emit('session:closed');
146
126
  }
147
- return true;
148
- }
149
127
 
150
- _sendPacket(packet) {
151
- if (!this.panelAddress || !this.panelPort) {
152
- this._log('[Error] No panel address/port set');
153
- return;
128
+ /**
129
+ * Arm partition in stay mode
130
+ */
131
+ armStay(partition, code) {
132
+ if (!this._checkEstablished()) return;
133
+ const packet = this.session.buildPartitionArm(partition, 0, code || this.masterCode);
134
+ this._sendPacket(packet);
154
135
  }
155
136
 
156
- this._log(`\n[UDP] TX to ${this.panelAddress}:${this.panelPort} (${packet.length} bytes)`);
157
- if (this.debug) {
158
- this._hexDump(packet);
137
+ /**
138
+ * Arm partition in away mode
139
+ */
140
+ armAway(partition, code) {
141
+ if (!this._checkEstablished()) return;
142
+ const packet = this.session.buildPartitionArm(partition, 1, code || this.masterCode);
143
+ this._sendPacket(packet);
159
144
  }
160
- this.server.send(packet, this.panelPort, this.panelAddress);
161
- }
162
-
163
- _handlePacket(data) {
164
- try {
165
- if (!this.session) {
166
- // Create session with appropriate logger based on log level
167
- const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {};
168
- this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
169
- this.emit('session:connecting');
170
- this._logMinimal(`[Session] Panel connecting from ${this.panelAddress}`);
171
- }
172
-
173
- const parsed = this.session.parsePacket(data);
174
- if (!parsed) {
175
- this._log('[Error] Failed to parse packet');
176
- return;
177
- }
178
-
179
- // Verbose logging only
180
- this._log(`\n[UDP] RX from ${this.panelAddress}:${this.panelPort} (${data.length} bytes)`);
181
- this._hexDump(data);
182
- this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
183
- this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
184
- this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
185
- if (parsed.appSequence !== null) {
186
- this._log(`[Packet] App Seq: ${parsed.appSequence}`);
187
- }
188
- if (parsed.commandData) {
189
- this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
190
- }
191
-
192
- // Route based on command
193
- this._routePacket(parsed);
194
-
195
- } catch (err) {
196
- this._log(`[Error] ${err.message}`);
197
- this.emit('error', err);
198
-
199
- // Reset on parse errors
200
- if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
201
- this._log('[Recovery] Parse error, resetting to wait for panel restart');
202
- this.handshakeState = 'WAITING';
203
- }
145
+
146
+ /**
147
+ * Disarm partition
148
+ */
149
+ disarm(partition, code) {
150
+ if (!this._checkEstablished()) return;
151
+ const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
152
+ this._sendPacket(packet);
204
153
  }
205
- }
206
154
 
207
- _routePacket(parsed) {
208
- const cmd = parsed.command;
155
+ /**
156
+ * Get current zone states
157
+ */
158
+ getZones() {
159
+ return this.eventHandler.getZoneStates();
160
+ }
209
161
 
210
- // Handle ACKs
211
- if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
212
- this._handleSimpleAck(parsed);
213
- return;
162
+ /**
163
+ * Get current partition states
164
+ */
165
+ getPartitions() {
166
+ return this.eventHandler.getPartitionStates();
214
167
  }
215
168
 
216
- // Route based on command type
217
- switch (cmd) {
218
- case CMD.OPEN_SESSION:
219
- this._handleOpenSession(parsed);
220
- break;
221
- case CMD.REQUEST_ACCESS:
222
- this._handleRequestAccess(parsed);
223
- break;
224
- case CMD.COMMAND_RESPONSE:
225
- this._handleCommandResponse(parsed);
226
- break;
227
- case CMD.COMMAND_ERROR:
228
- this._handleCommandError(parsed);
229
- break;
230
- case CMD.POLL:
231
- this._handlePoll(parsed);
232
- break;
233
- case CMD.LIFESTYLE_ZONE_STATUS:
234
- this._handleLifestyleZoneStatus(parsed);
235
- break;
236
- case CMD.TIME_DATE_BROADCAST:
237
- this._handleTimeDateBroadcast(parsed);
238
- break;
239
- default:
240
- if (this.handshakeState === 'ESTABLISHED') {
241
- this._log(`[Session] Unhandled command ${CMD_NAMES[cmd] || '0x' + cmd?.toString(16)}`);
242
- this._sendPacket(this.session.buildSimpleAck());
169
+ // ==================== Internal Methods ====================
170
+
171
+ _checkEstablished() {
172
+ if (this.handshakeState !== 'ESTABLISHED') {
173
+ this.emit('error', new Error('Session not established'));
174
+ return false;
243
175
  }
244
- break;
176
+ return true;
245
177
  }
246
- }
247
178
 
248
- // ==================== Handshake Handlers ====================
179
+ _sendPacket(packet) {
180
+ if (!this.panelAddress || !this.panelPort) {
181
+ this._log('[Error] No panel address/port set');
182
+ return;
183
+ }
249
184
 
250
- _handleOpenSession(parsed) {
251
- const data = parsed.commandData;
252
- if (!data || data.length < 14) {
253
- this._log('[Error] Invalid OPEN_SESSION data');
254
- return;
185
+ this._log(`\n[UDP] TX to ${this.panelAddress}:${this.panelPort} (${packet.length} bytes)`);
186
+ if (this.debug) {
187
+ this._hexDump(packet);
188
+ }
189
+ this.server.send(packet, this.panelPort, this.panelAddress);
255
190
  }
256
191
 
257
- const deviceType = data[0];
258
- const deviceId = data.readUInt16BE(1);
259
- const firmware = data.readUInt16BE(3);
260
- const protocol = data.readUInt16BE(5);
261
- const txBuffer = data.readUInt16BE(7);
262
- const rxBuffer = data.readUInt16BE(9);
263
- const encryptionType = data[13];
264
-
265
- this._log(`[Session] OPEN_SESSION received:`);
266
- this._log(` Device Type: ${deviceType}`);
267
- this._log(` Encryption Type: ${encryptionType}`);
268
- this._logMinimal('[Handshake] Starting session establishment...');
269
-
270
- // Handle panel restart mid-handshake
271
- if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
272
- this._log(`[Session] Panel restarting handshake (was in state ${this.handshakeState}), resetting session`);
273
- this.session = new ITv2Session(this.integrationId, this.accessCode, this._log.bind(this));
192
+ _handlePacket(data) {
193
+ try {
194
+ if (!this.session) {
195
+ // Create session with appropriate logger based on log level
196
+ const logger = this.logLevel === 'verbose' ? this._log.bind(this) : () => {
197
+ };
198
+ this.session = new ITv2Session(this.integrationId, this.accessCode, logger);
199
+ this.emit('session:connecting');
200
+ this._logMinimal(`[Session] Panel connecting from ${this.panelAddress}`);
201
+ }
202
+
203
+ const parsed = this.session.parsePacket(data);
204
+ if (!parsed) {
205
+ this._log('[Error] Failed to parse packet');
206
+ return;
207
+ }
208
+
209
+ // Verbose logging only
210
+ this._log(`\n[UDP] RX from ${this.panelAddress}:${this.panelPort} (${data.length} bytes)`);
211
+ this._hexDump(data);
212
+ this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
213
+ this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
214
+ this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
215
+ if (parsed.appSequence !== null) {
216
+ this._log(`[Packet] App Seq: ${parsed.appSequence}`);
217
+ }
218
+ if (parsed.commandData) {
219
+ this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
220
+ }
221
+
222
+ // Route based on command
223
+ this._routePacket(parsed);
224
+
225
+ } catch (err) {
226
+ this._log(`[Error] ${err.message}`);
227
+ this.emit('error', err);
228
+
229
+ // Reset on parse errors
230
+ if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
231
+ this._log('[Recovery] Parse error, resetting to wait for panel restart');
232
+ this.handshakeState = 'WAITING';
233
+ }
234
+ }
274
235
  }
275
236
 
276
- // Send COMMAND_RESPONSE echoing device type
277
- this._log(`[Handshake] Sending COMMAND_RESPONSE success (echoing device type ${deviceType})`);
278
- const response = this.session.buildCommandResponseWithAppSeq(deviceType, 0x00);
279
- this._sendPacket(response);
280
- this.handshakeState = 'SENT_CMD_RESPONSE_1';
281
- }
282
-
283
- _handleSimpleAck(parsed) {
284
- const expectedSeq = this.session.localSequence;
285
- this._log(`[Handshake] Got ACK ${parsed.receiverSequence}, state=${this.handshakeState}`);
286
-
287
- if (this.handshakeState === 'SENT_CMD_RESPONSE_1') {
288
- // Panel ACKed our first COMMAND_RESPONSE, send OPEN_SESSION
289
- this._log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
290
- const openSessionPayload = Buffer.from([
291
- 0x01, 0x80, 0x00, 0x00, // Device ID/type
292
- 0x02, 0x01, // Firmware
293
- 0x02, 0x41, // Protocol version
294
- 0x02, 0x00, // TX buffer
295
- 0x02, 0x00, // RX buffer
296
- 0x00, 0x01, 0x01 // Capabilities/encryption
297
- ]);
298
- const packet = this.session.buildOpenSessionResponse(openSessionPayload);
299
- this._sendPacket(packet);
300
- this.handshakeState = 'SENT_OPEN_SESSION';
301
- } else if (this.handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
302
- // Panel ACKed our COMMAND_RESPONSE to their REQUEST_ACCESS
303
- this._log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
304
-
305
- // Enable encryption with SEND key
306
- this.session.sendAesActive = true;
307
- this.session.sendAesKey = this.session.derivedSendKey;
308
- this._log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key`);
309
-
310
- // Build REQUEST_ACCESS based on detected encryption type
311
- let reqAccessPacket;
312
- if (this.detectedEncryptionType === 2) {
313
- this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
314
- reqAccessPacket = this.session.buildRequestAccessType2();
315
- } else {
316
- this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
317
- reqAccessPacket = this.session.buildRequestAccessType1();
318
- }
319
-
320
- this._sendPacket(reqAccessPacket);
321
-
322
- // Enable receive encryption
323
- this.session.receiveAesActive = true;
324
- this.session.receiveAesKey = this.session.pendingReceiveKey;
325
- this._log(`[Handshake] Enabled receive encryption`);
326
-
327
- this.handshakeState = 'SENT_REQUEST_ACCESS';
328
- } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
329
- // Panel ACKed our REQUEST_ACCESS - session established!
330
- this.handshakeState = 'ESTABLISHED';
331
- this._logMinimal(`✅ Session established (Type ${this.detectedEncryptionType} encryption)`);
332
- this._log(`[Handshake] *** SESSION ESTABLISHED ***`);
333
- this._log(`[Handshake] Encryption Type: ${this.detectedEncryptionType}`);
334
- this._log(`[Handshake] SEND key: ${this.session.derivedSendKey?.toString('hex')}`);
335
- this._log(`[Handshake] RECV key: ${this.session.pendingReceiveKey?.toString('hex')}`);
336
-
337
- this.emit('session:established', {
338
- encryptionType: this.detectedEncryptionType,
339
- sendKey: this.session.derivedSendKey?.toString('hex'),
340
- recvKey: this.session.pendingReceiveKey?.toString('hex')
341
- });
237
+ _routePacket(parsed) {
238
+ const cmd = parsed.command;
239
+
240
+ // Handle ACKs
241
+ if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
242
+ this._handleSimpleAck(parsed);
243
+ return;
244
+ }
245
+
246
+ // Route based on command type
247
+ switch (cmd) {
248
+ case CMD.OPEN_SESSION:
249
+ this._handleOpenSession(parsed);
250
+ break;
251
+ case CMD.REQUEST_ACCESS:
252
+ this._handleRequestAccess(parsed);
253
+ break;
254
+ case CMD.COMMAND_RESPONSE:
255
+ this._handleCommandResponse(parsed);
256
+ break;
257
+ case CMD.COMMAND_ERROR:
258
+ this._handleCommandError(parsed);
259
+ break;
260
+ case CMD.POLL:
261
+ this._handlePoll(parsed);
262
+ break;
263
+ case CMD.LIFESTYLE_ZONE_STATUS:
264
+ this._handleLifestyleZoneStatus(parsed);
265
+ break;
266
+ case CMD.TIME_DATE_BROADCAST:
267
+ this._handleTimeDateBroadcast(parsed);
268
+ break;
269
+ default:
270
+ if (this.handshakeState === 'ESTABLISHED') {
271
+ this._log(`[Session] Unhandled command ${CMD_NAMES[cmd] || '0x' + cmd?.toString(16)}`);
272
+ this._sendPacket(this.session.buildSimpleAck());
273
+ }
274
+ break;
275
+ }
342
276
  }
343
- }
344
277
 
345
- _handleRequestAccess(parsed) {
346
- const data = parsed.commandData;
347
- if (!data || data.length < 17) {
348
- this._log('[Error] Invalid REQUEST_ACCESS data');
349
- return;
278
+ // ==================== Handshake Handlers ====================
279
+
280
+ _handleOpenSession(parsed) {
281
+ const data = parsed.commandData;
282
+ if (!data || data.length < 14) {
283
+ this._log('[Error] Invalid OPEN_SESSION data');
284
+ return;
285
+ }
286
+
287
+ const deviceType = data[0];
288
+ const deviceId = data.readUInt16BE(1);
289
+ const firmware = data.readUInt16BE(3);
290
+ const protocol = data.readUInt16BE(5);
291
+ const txBuffer = data.readUInt16BE(7);
292
+ const rxBuffer = data.readUInt16BE(9);
293
+ const encryptionType = data[13];
294
+
295
+ this._log(`[Session] OPEN_SESSION received:`);
296
+ this._log(` Device Type: ${deviceType}`);
297
+ this._log(` Encryption Type: ${encryptionType}`);
298
+ this._logMinimal('[Handshake] Starting session establishment...');
299
+
300
+ // Handle panel restart mid-handshake
301
+ if (this.handshakeState !== 'WAITING' && this.handshakeState !== 'SENT_CMD_RESPONSE_1') {
302
+ this._log(`[Session] Panel restarting handshake (was in state ${this.handshakeState}), resetting session`);
303
+ this.session = new ITv2Session(this.integrationId, this.accessCode, this._log.bind(this));
304
+ }
305
+
306
+ // Send COMMAND_RESPONSE echoing device type
307
+ this._log(`[Handshake] Sending COMMAND_RESPONSE success (echoing device type ${deviceType})`);
308
+ const response = this.session.buildCommandResponseWithAppSeq(deviceType, 0x00);
309
+ this._sendPacket(response);
310
+ this.handshakeState = 'SENT_CMD_RESPONSE_1';
350
311
  }
351
312
 
352
- const initType = data[0];
353
- const initializer = data.slice(1);
354
-
355
- this._log(`[Handshake] Got panel's REQUEST_ACCESS`);
356
- this._log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
357
-
358
- // Determine encryption type based on initializer length
359
- if (initializer.length === 48) {
360
- // Type 1 encryption
361
- this.detectedEncryptionType = 1;
362
- this._log(`[Handshake] Encryption Type 1 (48-byte initializer)`);
363
-
364
- const sendKey = parseType1Initializer(this.accessCode, initializer, this.integrationId, this.logLevel === 'verbose');
365
- this._logMinimal('[Handshake] Type 1 encryption negotiated');
366
- this._log(`[Handshake] Type 1 SEND key derived`);
367
- this.session.derivedSendKey = sendKey;
368
-
369
- } else if (initializer.length === 16) {
370
- // Type 2 encryption
371
- this.detectedEncryptionType = 2;
372
- this._log(`[Handshake] Encryption Type 2 (16-byte initializer)`);
373
-
374
- // Type 2: Symmetric transform with 32-hex access code
375
- let accessCode32 = this.accessCode;
376
- if (accessCode32.length === 8) {
377
- accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
378
- }
379
-
380
- const sendKey = type2InitializerTransform(accessCode32, initializer);
381
- this._logMinimal('[Handshake] Type 2 encryption negotiated');
382
- this._log(`[Handshake] Type 2 SEND key derived`);
383
- this.session.derivedSendKey = sendKey;
384
-
385
- } else {
386
- this._log(`[Error] Unknown initializer length: ${initializer.length}`);
387
- return;
313
+ _handleSimpleAck(parsed) {
314
+ const expectedSeq = this.session.localSequence;
315
+ this._log(`[Handshake] Got ACK ${parsed.receiverSequence}, state=${this.handshakeState}`);
316
+
317
+ if (this.handshakeState === 'SENT_CMD_RESPONSE_1') {
318
+ // Panel ACKed our first COMMAND_RESPONSE, send OPEN_SESSION
319
+ this._log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
320
+ const openSessionPayload = Buffer.from([
321
+ 0x01, 0x80, 0x00, 0x00, // Device ID/type
322
+ 0x02, 0x01, // Firmware
323
+ 0x02, 0x41, // Protocol version
324
+ 0x02, 0x00, // TX buffer
325
+ 0x02, 0x00, // RX buffer
326
+ 0x00, 0x01, 0x01 // Capabilities/encryption
327
+ ]);
328
+ const packet = this.session.buildOpenSessionResponse(openSessionPayload);
329
+ this._sendPacket(packet);
330
+ this.handshakeState = 'SENT_OPEN_SESSION';
331
+ } else if (this.handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
332
+ // Panel ACKed our COMMAND_RESPONSE to their REQUEST_ACCESS
333
+ this._log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
334
+
335
+ // Enable encryption with SEND key
336
+ this.session.sendAesActive = true;
337
+ this.session.sendAesKey = this.session.derivedSendKey;
338
+ this._log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key`);
339
+
340
+ // Build REQUEST_ACCESS based on detected encryption type
341
+ let reqAccessPacket;
342
+ if (this.detectedEncryptionType === 2) {
343
+ this._log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
344
+ reqAccessPacket = this.session.buildRequestAccessType2();
345
+ } else {
346
+ this._log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
347
+ reqAccessPacket = this.session.buildRequestAccessType1();
348
+ }
349
+
350
+ this._sendPacket(reqAccessPacket);
351
+
352
+ // Enable receive encryption
353
+ this.session.receiveAesActive = true;
354
+ this.session.receiveAesKey = this.session.pendingReceiveKey;
355
+ this._log(`[Handshake] Enabled receive encryption`);
356
+
357
+ this.handshakeState = 'SENT_REQUEST_ACCESS';
358
+ } else if (this.handshakeState === 'SENT_REQUEST_ACCESS') {
359
+ // Panel ACKed our REQUEST_ACCESS - session established!
360
+ this.handshakeState = 'ESTABLISHED';
361
+ this._logMinimal(`✅ Session established (Type ${this.detectedEncryptionType} encryption)`);
362
+ this._log(`[Handshake] *** SESSION ESTABLISHED ***`);
363
+ this._log(`[Handshake] Encryption Type: ${this.detectedEncryptionType}`);
364
+ this._log(`[Handshake] SEND key: ${this.session.derivedSendKey?.toString('hex')}`);
365
+ this._log(`[Handshake] RECV key: ${this.session.pendingReceiveKey?.toString('hex')}`);
366
+
367
+ this.emit('session:established', {
368
+ encryptionType: this.detectedEncryptionType,
369
+ sendKey: this.session.derivedSendKey?.toString('hex'),
370
+ recvKey: this.session.pendingReceiveKey?.toString('hex')
371
+ });
372
+ }
388
373
  }
389
374
 
390
- // Send plaintext COMMAND_RESPONSE
391
- this._log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
392
- const appSeq = parsed.appSequence;
393
- const response = this.session.buildCommandResponseWithAppSeq(appSeq, 0x00);
394
- this._sendPacket(response);
375
+ _handleRequestAccess(parsed) {
376
+ const data = parsed.commandData;
377
+ if (!data || data.length < 17) {
378
+ this._log('[Error] Invalid REQUEST_ACCESS data');
379
+ return;
380
+ }
395
381
 
396
- this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
397
- }
382
+ const initType = data[0];
383
+ const initializer = data.slice(1);
384
+
385
+ this._log(`[Handshake] Got panel's REQUEST_ACCESS`);
386
+ this._log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
387
+
388
+ // Determine encryption type based on initializer length
389
+ if (initializer.length === 48) {
390
+ // Type 1 encryption
391
+ this.detectedEncryptionType = 1;
392
+ this._log(`[Handshake] Encryption Type 1 (48-byte initializer)`);
393
+
394
+ const sendKey = parseType1Initializer(this.accessCode, initializer, this.integrationId, this.logLevel === 'verbose');
395
+ this._logMinimal('[Handshake] Type 1 encryption negotiated');
396
+ this._log(`[Handshake] Type 1 SEND key derived`);
397
+ this.session.derivedSendKey = sendKey;
398
+
399
+ } else if (initializer.length === 16) {
400
+ // Type 2 encryption
401
+ this.detectedEncryptionType = 2;
402
+ this._log(`[Handshake] Encryption Type 2 (16-byte initializer)`);
403
+
404
+ // Type 2: Symmetric transform with 32-hex access code
405
+ let accessCode32 = this.accessCode;
406
+ if (accessCode32.length === 8) {
407
+ accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
408
+ }
409
+
410
+ const sendKey = type2InitializerTransform(accessCode32, initializer);
411
+ this._logMinimal('[Handshake] Type 2 encryption negotiated');
412
+ this._log(`[Handshake] Type 2 SEND key derived`);
413
+ this.session.derivedSendKey = sendKey;
414
+
415
+ } else {
416
+ this._log(`[Error] Unknown initializer length: ${initializer.length}`);
417
+ return;
418
+ }
398
419
 
399
- _handleCommandResponse(parsed) {
400
- const responseCode = parsed.commandData?.[0] || 0;
401
- const appSeqAsEcho = parsed.appSequence;
420
+ // Send plaintext COMMAND_RESPONSE
421
+ this._log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
422
+ const appSeq = parsed.appSequence;
423
+ const response = this.session.buildCommandResponseWithAppSeq(appSeq, 0x00);
424
+ this._sendPacket(response);
402
425
 
403
- this._log(`[Handshake] Got COMMAND_RESPONSE (echoed app_seq: ${appSeqAsEcho}, response_code: ${responseCode}), sending ACK`);
426
+ this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
427
+ }
404
428
 
405
- const ack = this.session.buildSimpleAck();
406
- this._sendPacket(ack);
429
+ _handleCommandResponse(parsed) {
430
+ const responseCode = parsed.commandData?.[0] || 0;
431
+ const appSeqAsEcho = parsed.appSequence;
407
432
 
408
- if (this.handshakeState === 'SENT_OPEN_SESSION') {
409
- // Panel accepted our OPEN_SESSION
410
- this.handshakeState = 'WAITING_REQUEST_ACCESS';
433
+ this._log(`[Handshake] Got COMMAND_RESPONSE (echoed app_seq: ${appSeqAsEcho}, response_code: ${responseCode}), sending ACK`);
434
+
435
+ const ack = this.session.buildSimpleAck();
436
+ this._sendPacket(ack);
437
+
438
+ if (this.handshakeState === 'SENT_OPEN_SESSION') {
439
+ // Panel accepted our OPEN_SESSION
440
+ this.handshakeState = 'WAITING_REQUEST_ACCESS';
441
+ }
411
442
  }
412
- }
413
443
 
414
- _handleCommandError(parsed) {
415
- const errorData = parsed.commandData;
416
- const error = parseCommandError(errorData);
444
+ _handleCommandError(parsed) {
445
+ const errorData = parsed.commandData;
446
+ const error = parseCommandError(errorData);
417
447
 
418
- this._log(`[Error] COMMAND_ERROR: ${error.message} (${error.rawData})`);
419
- this.emit('command:error', error);
448
+ this._log(`[Error] COMMAND_ERROR: ${error.message} (${error.rawData})`);
449
+ this.emit('command:error', error);
450
+
451
+ // Send ACK
452
+ this._sendPacket(this.session.buildSimpleAck());
453
+ }
420
454
 
421
- // Send ACK
422
- this._sendPacket(this.session.buildSimpleAck());
423
- }
455
+ _handlePoll(parsed) {
456
+ this._log(`[Session] Got POLL, sending ACK`);
457
+ this._sendPacket(this.session.buildSimpleAck());
458
+ }
424
459
 
425
- _handlePoll(parsed) {
426
- this._log(`[Session] Got POLL, sending ACK`);
427
- this._sendPacket(this.session.buildSimpleAck());
428
- }
460
+ // ==================== Notification Handlers ====================
429
461
 
430
- // ==================== Notification Handlers ====================
462
+ _handleLifestyleZoneStatus(parsed) {
463
+ const data = parsed.commandData;
431
464
 
432
- _handleLifestyleZoneStatus(parsed) {
433
- const data = parsed.commandData;
465
+ if (data && data.length >= 2) {
466
+ const zoneNum = data[0];
467
+ const statusValue = data[1];
434
468
 
435
- if (data && data.length >= 2) {
436
- const zoneNum = data[0];
437
- const statusValue = data[1];
469
+ // Update internal state and get full parsed status
470
+ const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
438
471
 
439
- // Update internal state and get full parsed status
440
- const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
472
+ // Emit events with full status object
473
+ this.emit('zone:status', zoneNum, fullStatus);
441
474
 
442
- // Emit events with full status object
443
- this.emit('zone:status', zoneNum, fullStatus);
475
+ if (fullStatus.open) {
476
+ this.emit('zone:open', zoneNum);
477
+ this._log(`[Zone ${zoneNum}] OPEN (status: ${statusValue})`);
478
+ } else {
479
+ this.emit('zone:closed', zoneNum);
480
+ this._log(`[Zone ${zoneNum}] CLOSED (status: ${statusValue})`);
481
+ }
482
+ }
444
483
 
445
- if (fullStatus.open) {
446
- this.emit('zone:open', zoneNum);
447
- this._log(`[Zone ${zoneNum}] OPEN (status: ${statusValue})`);
448
- } else {
449
- this.emit('zone:closed', zoneNum);
450
- this._log(`[Zone ${zoneNum}] CLOSED (status: ${statusValue})`);
451
- }
484
+ this._sendPacket(this.session.buildSimpleAck());
452
485
  }
453
486
 
454
- this._sendPacket(this.session.buildSimpleAck());
455
- }
456
-
457
- _handleTimeDateBroadcast(parsed) {
458
- const data = parsed.commandData;
459
- if (data && data.length >= 5) {
460
- this._log(`[Time/Date] Received broadcast`);
461
- this.emit('notification:timeDate', data);
487
+ _handleTimeDateBroadcast(parsed) {
488
+ const data = parsed.commandData;
489
+ if (data && data.length >= 5) {
490
+ this._log(`[Time/Date] Received broadcast`);
491
+ this.emit('notification:timeDate', data);
492
+ }
493
+ this._sendPacket(this.session.buildSimpleAck());
462
494
  }
463
- this._sendPacket(this.session.buildSimpleAck());
464
- }
465
495
 
466
- // ==================== Utility Methods ====================
496
+ // ==================== Utility Methods ====================
467
497
 
468
- _handleError(error) {
469
- this._log(`[Error] ${error.message}`);
470
- this.emit('error', error);
471
- }
498
+ _handleError(error) {
499
+ this._log(`[Error] ${error.message}`);
500
+ this.emit('error', error);
501
+ }
472
502
 
473
- _log(message) {
474
- if (this.logLevel === 'verbose') {
475
- const timestamp = new Date().toISOString();
476
- console.log(`[${timestamp}] ${message}`);
503
+ _log(message) {
504
+ if (this.logLevel === 'verbose') {
505
+ const timestamp = new Date().toISOString();
506
+ console.log(`[${timestamp}] ${message}`);
507
+ }
477
508
  }
478
- }
479
509
 
480
- _logMinimal(message) {
481
- if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
482
- console.log(message);
510
+ _logMinimal(message) {
511
+ if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
512
+ console.log(message);
513
+ }
483
514
  }
484
- }
485
-
486
- _hexDump(buffer) {
487
- if (this.logLevel !== 'verbose') return;
488
-
489
- for (let i = 0; i < buffer.length; i += 16) {
490
- const chunk = buffer.slice(i, i + 16);
491
- const hex = Array.from(chunk)
492
- .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
493
- .join(' ');
494
- const ascii = Array.from(chunk)
495
- .map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
496
- .join('');
497
-
498
- const offset = i.toString(16).padStart(4, '0').toUpperCase();
499
- console.log(`${offset} ${hex.padEnd(48)} |${ascii}|`);
515
+
516
+ _hexDump(buffer) {
517
+ if (this.logLevel !== 'verbose') return;
518
+
519
+ for (let i = 0; i < buffer.length; i += 16) {
520
+ const chunk = buffer.slice(i, i + 16);
521
+ const hex = Array.from(chunk)
522
+ .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
523
+ .join(' ');
524
+ const ascii = Array.from(chunk)
525
+ .map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
526
+ .join('');
527
+
528
+ const offset = i.toString(16).padStart(4, '0').toUpperCase();
529
+ console.log(`${offset} ${hex.padEnd(48)} |${ascii}|`);
530
+ }
500
531
  }
501
- }
502
532
  }
@@ -64,12 +64,6 @@ client.on('error', (error) => {
64
64
  console.error('❌ Error:', error.message);
65
65
  });
66
66
 
67
- // Graceful shutdown
68
- process.on('SIGINT', async () => {
69
- console.log('\n\n🛑 Shutting down gracefully...');
70
- await client.stop();
71
- process.exit(0);
72
- });
73
67
 
74
68
  // Start the client
75
69
  console.log('╔════════════════════════════════════════╗');