dsc-itv2-client 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +342 -0
- package/package.json +46 -0
- package/src/ITV2Client.js +502 -0
- package/src/constants.js +106 -0
- package/src/event-handler.js +323 -0
- package/src/examples/README.md +287 -0
- package/src/examples/arm-disarm-example.js +152 -0
- package/src/examples/basic-monitoring.js +82 -0
- package/src/examples/interactive-cli.js +1033 -0
- package/src/index.js +17 -0
- package/src/itv2-crypto.js +310 -0
- package/src/itv2-session.js +1069 -0
- package/src/response-parsers.js +185 -0
- package/src/utils.js +240 -0
|
@@ -0,0 +1,502 @@
|
|
|
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 { 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 || 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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.server) {
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
this.server.close(() => {
|
|
90
|
+
this._log('[Shutdown] UDP server closed');
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
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;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_sendPacket(packet) {
|
|
151
|
+
if (!this.panelAddress || !this.panelPort) {
|
|
152
|
+
this._log('[Error] No panel address/port set');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this._log(`\n[UDP] TX to ${this.panelAddress}:${this.panelPort} (${packet.length} bytes)`);
|
|
157
|
+
if (this.debug) {
|
|
158
|
+
this._hexDump(packet);
|
|
159
|
+
}
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_routePacket(parsed) {
|
|
208
|
+
const cmd = parsed.command;
|
|
209
|
+
|
|
210
|
+
// Handle ACKs
|
|
211
|
+
if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
|
|
212
|
+
this._handleSimpleAck(parsed);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
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());
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ==================== Handshake Handlers ====================
|
|
249
|
+
|
|
250
|
+
_handleOpenSession(parsed) {
|
|
251
|
+
const data = parsed.commandData;
|
|
252
|
+
if (!data || data.length < 14) {
|
|
253
|
+
this._log('[Error] Invalid OPEN_SESSION data');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
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));
|
|
274
|
+
}
|
|
275
|
+
|
|
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
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_handleRequestAccess(parsed) {
|
|
346
|
+
const data = parsed.commandData;
|
|
347
|
+
if (!data || data.length < 17) {
|
|
348
|
+
this._log('[Error] Invalid REQUEST_ACCESS data');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
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;
|
|
388
|
+
}
|
|
389
|
+
|
|
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);
|
|
395
|
+
|
|
396
|
+
this.handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_handleCommandResponse(parsed) {
|
|
400
|
+
const responseCode = parsed.commandData?.[0] || 0;
|
|
401
|
+
const appSeqAsEcho = parsed.appSequence;
|
|
402
|
+
|
|
403
|
+
this._log(`[Handshake] Got COMMAND_RESPONSE (echoed app_seq: ${appSeqAsEcho}, response_code: ${responseCode}), sending ACK`);
|
|
404
|
+
|
|
405
|
+
const ack = this.session.buildSimpleAck();
|
|
406
|
+
this._sendPacket(ack);
|
|
407
|
+
|
|
408
|
+
if (this.handshakeState === 'SENT_OPEN_SESSION') {
|
|
409
|
+
// Panel accepted our OPEN_SESSION
|
|
410
|
+
this.handshakeState = 'WAITING_REQUEST_ACCESS';
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_handleCommandError(parsed) {
|
|
415
|
+
const errorData = parsed.commandData;
|
|
416
|
+
const error = parseCommandError(errorData);
|
|
417
|
+
|
|
418
|
+
this._log(`[Error] COMMAND_ERROR: ${error.message} (${error.rawData})`);
|
|
419
|
+
this.emit('command:error', error);
|
|
420
|
+
|
|
421
|
+
// Send ACK
|
|
422
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
_handlePoll(parsed) {
|
|
426
|
+
this._log(`[Session] Got POLL, sending ACK`);
|
|
427
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ==================== Notification Handlers ====================
|
|
431
|
+
|
|
432
|
+
_handleLifestyleZoneStatus(parsed) {
|
|
433
|
+
const data = parsed.commandData;
|
|
434
|
+
|
|
435
|
+
if (data && data.length >= 2) {
|
|
436
|
+
const zoneNum = data[0];
|
|
437
|
+
const statusValue = data[1];
|
|
438
|
+
|
|
439
|
+
// Update internal state and get full parsed status
|
|
440
|
+
const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusValue);
|
|
441
|
+
|
|
442
|
+
// Emit events with full status object
|
|
443
|
+
this.emit('zone:status', zoneNum, fullStatus);
|
|
444
|
+
|
|
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
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
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);
|
|
462
|
+
}
|
|
463
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ==================== Utility Methods ====================
|
|
467
|
+
|
|
468
|
+
_handleError(error) {
|
|
469
|
+
this._log(`[Error] ${error.message}`);
|
|
470
|
+
this.emit('error', error);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_log(message) {
|
|
474
|
+
if (this.logLevel === 'verbose') {
|
|
475
|
+
const timestamp = new Date().toISOString();
|
|
476
|
+
console.log(`[${timestamp}] ${message}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
_logMinimal(message) {
|
|
481
|
+
if (this.logLevel === 'minimal' || this.logLevel === 'verbose') {
|
|
482
|
+
console.log(message);
|
|
483
|
+
}
|
|
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}|`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ITV2 Protocol Constants
|
|
3
|
+
* Based on reverse engineering of DSC Neo DLLs and TSP Interactive SDK documentation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Connection settings (per SDK documentation)
|
|
7
|
+
export const DEFAULT_HOST = '192.168.0.144'; // TL280R IP (not used - it connects to us)
|
|
8
|
+
export const DEFAULT_UDP_PORT = 3073; // Integration Polling Port (section [430] = 0x0C01)
|
|
9
|
+
export const DEFAULT_TCP_PORT = 3072; // Integration Notification Port (section [429] = 0x0C00)
|
|
10
|
+
export const DEFAULT_PORT = DEFAULT_TCP_PORT; // Legacy alias
|
|
11
|
+
|
|
12
|
+
// Channel functions (from SDK)
|
|
13
|
+
export const ChannelFunction = {
|
|
14
|
+
NO_FUNCTION: 0,
|
|
15
|
+
POLL_ONLY: 1,
|
|
16
|
+
POLL_AND_NOTIFY: 2,
|
|
17
|
+
NOTIFICATIONS: 3,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Command codes (16-bit: high byte = category, low byte = command)
|
|
21
|
+
export const Commands = {
|
|
22
|
+
// Response codes (0x00xx, 0x05xx)
|
|
23
|
+
SIMPLE_ACK: 0x0000,
|
|
24
|
+
COMMAND_ERROR: 0x0501,
|
|
25
|
+
COMMAND_RESPONSE: 0x0502,
|
|
26
|
+
|
|
27
|
+
// Session management (0x06xx)
|
|
28
|
+
POLL: 0x0600,
|
|
29
|
+
OPEN_SESSION: 0x060A,
|
|
30
|
+
END_SESSION: 0x060B,
|
|
31
|
+
BUFFER_SIZES: 0x060C,
|
|
32
|
+
REQUEST_ACCESS: 0x060E,
|
|
33
|
+
SYSTEM_CAPABILITIES: 0x0613,
|
|
34
|
+
|
|
35
|
+
// Zone/Lifestyle events (0x02xx)
|
|
36
|
+
LIFESTYLE_ZONE_STATUS: 0x0210,
|
|
37
|
+
EXIT_DELAY: 0x0230,
|
|
38
|
+
ENTRY_DELAY: 0x0231,
|
|
39
|
+
ARMING_DISARMING: 0x0232,
|
|
40
|
+
ARMING_PREALERT: 0x0233,
|
|
41
|
+
PARTITION_QUICK_EXIT: 0x0238,
|
|
42
|
+
PARTITION_READY_STATUS: 0x0239,
|
|
43
|
+
PARTITION_AUDIBLE_BELL: 0x023B,
|
|
44
|
+
PARTITION_ALARM_MEMORY: 0x023C,
|
|
45
|
+
MISC_PREALERT: 0x023D,
|
|
46
|
+
PARTITION_TROUBLE_STATUS: 0x023F,
|
|
47
|
+
PARTITION_BYPASS_STATUS: 0x0240,
|
|
48
|
+
|
|
49
|
+
// Configuration (0x07xx)
|
|
50
|
+
ZONE_ASSIGNMENT_CONFIG: 0x0770,
|
|
51
|
+
CONFIGURATION: 0x0771,
|
|
52
|
+
PARTITION_ASSIGNMENT_CONFIG: 0x0772,
|
|
53
|
+
|
|
54
|
+
// Status queries (0x08xx)
|
|
55
|
+
GLOBAL_STATUS: 0x0810,
|
|
56
|
+
ZONE_STATUS: 0x0811,
|
|
57
|
+
PARTITION_STATUS: 0x0812,
|
|
58
|
+
ZONE_BYPASS_STATUS: 0x0813,
|
|
59
|
+
SINGLE_ZONE_BYPASS_STATUS: 0x0820,
|
|
60
|
+
SYSTEM_TROUBLE_STATUS: 0x0822,
|
|
61
|
+
TROUBLE_DETAIL: 0x0823,
|
|
62
|
+
ZONE_ALARM_STATUS: 0x0840,
|
|
63
|
+
MISC_ALARM_STATUS: 0x0841,
|
|
64
|
+
|
|
65
|
+
// LCD/Keypad (0x0Fxx)
|
|
66
|
+
LCD_UPDATE: 0x0F02,
|
|
67
|
+
LCD_CURSOR: 0x0F03,
|
|
68
|
+
LED_STATUS: 0x0F04,
|
|
69
|
+
|
|
70
|
+
// Access codes (0x47xx)
|
|
71
|
+
ACCESS_CODES_RESPONSE: 0x4736,
|
|
72
|
+
ACCESS_CODES_PARTITION_RESPONSE: 0x4738,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Command names for logging
|
|
76
|
+
export const CommandNames = Object.fromEntries(
|
|
77
|
+
Object.entries(Commands).map(([name, code]) => [code, name])
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Get command name from code
|
|
81
|
+
export function getCommandName(code) {
|
|
82
|
+
return CommandNames[code] || `UNKNOWN_${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// CRC-16 common polynomials to try
|
|
86
|
+
export const CRC16_POLYNOMIALS = {
|
|
87
|
+
CCITT: 0x1021,
|
|
88
|
+
IBM: 0x8005,
|
|
89
|
+
T10_DIF: 0x8BB7,
|
|
90
|
+
DNP: 0x3D65,
|
|
91
|
+
MODBUS: 0x8005,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Common CRC-16 seeds
|
|
95
|
+
export const CRC16_SEEDS = {
|
|
96
|
+
ZERO: 0x0000,
|
|
97
|
+
FFFF: 0xFFFF,
|
|
98
|
+
ONE: 0x0001,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Possible frame markers (to be determined from captures)
|
|
102
|
+
export const FRAME_MARKERS = {
|
|
103
|
+
POSSIBLE_START: [0x7E, 0xFE, 0xFF, 0xAA, 0x55, 0x02],
|
|
104
|
+
POSSIBLE_END: [0x7E, 0x03, 0x0D, 0x0A],
|
|
105
|
+
ESCAPE: 0x7D,
|
|
106
|
+
};
|