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 +1 -1
- package/package.json +1 -1
- package/src/ITV2Client.js +464 -434
- package/src/examples/basic-monitoring.js +0 -6
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.
|
|
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 {
|
|
6
|
+
import {EventEmitter} from 'events';
|
|
7
7
|
import dgram from 'dgram';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
208
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Get current zone states
|
|
157
|
+
*/
|
|
158
|
+
getZones() {
|
|
159
|
+
return this.eventHandler.getZoneStates();
|
|
160
|
+
}
|
|
209
161
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Get current partition states
|
|
164
|
+
*/
|
|
165
|
+
getPartitions() {
|
|
166
|
+
return this.eventHandler.getPartitionStates();
|
|
214
167
|
}
|
|
215
168
|
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
176
|
+
return true;
|
|
245
177
|
}
|
|
246
|
-
}
|
|
247
178
|
|
|
248
|
-
|
|
179
|
+
_sendPacket(packet) {
|
|
180
|
+
if (!this.panelAddress || !this.panelPort) {
|
|
181
|
+
this._log('[Error] No panel address/port set');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
249
184
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
426
|
+
this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
|
|
427
|
+
}
|
|
404
428
|
|
|
405
|
-
|
|
406
|
-
|
|
429
|
+
_handleCommandResponse(parsed) {
|
|
430
|
+
const responseCode = parsed.commandData?.[0] || 0;
|
|
431
|
+
const appSeqAsEcho = parsed.appSequence;
|
|
407
432
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
444
|
+
_handleCommandError(parsed) {
|
|
445
|
+
const errorData = parsed.commandData;
|
|
446
|
+
const error = parseCommandError(errorData);
|
|
417
447
|
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
455
|
+
_handlePoll(parsed) {
|
|
456
|
+
this._log(`[Session] Got POLL, sending ACK`);
|
|
457
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
458
|
+
}
|
|
424
459
|
|
|
425
|
-
|
|
426
|
-
this._log(`[Session] Got POLL, sending ACK`);
|
|
427
|
-
this._sendPacket(this.session.buildSimpleAck());
|
|
428
|
-
}
|
|
460
|
+
// ==================== Notification Handlers ====================
|
|
429
461
|
|
|
430
|
-
|
|
462
|
+
_handleLifestyleZoneStatus(parsed) {
|
|
463
|
+
const data = parsed.commandData;
|
|
431
464
|
|
|
432
|
-
|
|
433
|
-
|
|
465
|
+
if (data && data.length >= 2) {
|
|
466
|
+
const zoneNum = data[0];
|
|
467
|
+
const statusValue = data[1];
|
|
434
468
|
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
472
|
+
// Emit events with full status object
|
|
473
|
+
this.emit('zone:status', zoneNum, fullStatus);
|
|
441
474
|
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
496
|
+
// ==================== Utility Methods ====================
|
|
467
497
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
498
|
+
_handleError(error) {
|
|
499
|
+
this._log(`[Error] ${error.message}`);
|
|
500
|
+
this.emit('error', error);
|
|
501
|
+
}
|
|
472
502
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
510
|
+
_logMinimal(message) {
|
|
511
|
+
if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
|
|
512
|
+
console.log(message);
|
|
513
|
+
}
|
|
483
514
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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('╔════════════════════════════════════════╗');
|