dsc-itv2-client 1.0.22 → 1.0.24
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/package.json +1 -1
- package/src/ITV2Client.js +238 -16
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.24",
|
|
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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import {EventEmitter} from 'events';
|
|
7
7
|
import dgram from 'dgram';
|
|
8
|
+
import net from 'net';
|
|
8
9
|
import {ITv2Session, CMD, CMD_NAMES} from './itv2-session.js';
|
|
9
10
|
import {parseType1Initializer, type2InitializerTransform} from './itv2-crypto.js';
|
|
10
11
|
import {EventHandler} from './event-handler.js';
|
|
@@ -22,18 +23,23 @@ export class ITV2Client extends EventEmitter {
|
|
|
22
23
|
this.integrationId = options.integrationId;
|
|
23
24
|
this.accessCode = options.accessCode;
|
|
24
25
|
this.masterCode = options.masterCode || '5555';
|
|
25
|
-
this.port = options.port || 3073;
|
|
26
|
+
this.port = options.port || 3073; // UDP polling port
|
|
27
|
+
this.tcpPort = options.tcpPort || 3072; // TCP notification port
|
|
26
28
|
this.logLevel = options.logLevel || (options.debug === true ? 'verbose' : 'minimal'); // 'silent', 'minimal', 'verbose'
|
|
27
29
|
this.encryptionType = options.encryptionType || null; // null = auto-detect, 1 = Type 1, 2 = Type 2
|
|
28
30
|
|
|
29
31
|
// Internal state
|
|
30
|
-
this.server = null;
|
|
32
|
+
this.server = null; // UDP server
|
|
33
|
+
this.tcpServer = null; // TCP server
|
|
34
|
+
this.tcpClients = new Map(); // Active TCP connections
|
|
31
35
|
this.session = null;
|
|
32
36
|
this.eventHandler = new EventHandler();
|
|
33
37
|
this.handshakeState = 'WAITING';
|
|
34
38
|
this.panelAddress = null;
|
|
35
39
|
this.panelPort = null;
|
|
36
40
|
this.detectedEncryptionType = null; // Set during handshake
|
|
41
|
+
this._lastTransport = 'udp'; // Track last packet source
|
|
42
|
+
this._lastTcpSocket = null; // Track TCP socket for responses
|
|
37
43
|
|
|
38
44
|
// Bind methods
|
|
39
45
|
this._handlePacket = this._handlePacket.bind(this);
|
|
@@ -69,6 +75,9 @@ export class ITV2Client extends EventEmitter {
|
|
|
69
75
|
|
|
70
76
|
this.server.bind(this.port, '0.0.0.0');
|
|
71
77
|
|
|
78
|
+
// Start TCP server for notification port
|
|
79
|
+
this._startTcpServer();
|
|
80
|
+
|
|
72
81
|
// Register signal handlers (only once)
|
|
73
82
|
if (!this._boundShutdown) {
|
|
74
83
|
this._boundShutdown = async () => {
|
|
@@ -81,6 +90,97 @@ export class ITV2Client extends EventEmitter {
|
|
|
81
90
|
});
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Start TCP server for notification port (3072)
|
|
95
|
+
*/
|
|
96
|
+
_startTcpServer() {
|
|
97
|
+
this.tcpServer = net.createServer((socket) => {
|
|
98
|
+
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
99
|
+
this._logMinimal(`[TCP] Client connected: ${clientId}`);
|
|
100
|
+
this.tcpClients.set(clientId, socket);
|
|
101
|
+
|
|
102
|
+
// Store panel address from TCP connection too
|
|
103
|
+
if (!this.panelAddress) {
|
|
104
|
+
this.panelAddress = socket.remoteAddress;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let buffer = Buffer.alloc(0);
|
|
108
|
+
|
|
109
|
+
socket.on('data', (data) => {
|
|
110
|
+
this._logMinimal(`[TCP] Received ${data.length} bytes from ${clientId}`);
|
|
111
|
+
this._logMinimal(`[TCP] Raw data: ${data.toString('hex')}`);
|
|
112
|
+
|
|
113
|
+
// Accumulate data in buffer
|
|
114
|
+
buffer = Buffer.concat([buffer, data]);
|
|
115
|
+
|
|
116
|
+
// Try to parse complete packets (look for 0x7E...0x7F framing)
|
|
117
|
+
this._processTcpBuffer(buffer, socket, (remaining) => {
|
|
118
|
+
buffer = remaining;
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
socket.on('close', () => {
|
|
123
|
+
this._logMinimal(`[TCP] Client disconnected: ${clientId}`);
|
|
124
|
+
this.tcpClients.delete(clientId);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
socket.on('error', (err) => {
|
|
128
|
+
this._log(`[TCP] Socket error for ${clientId}: ${err.message}`);
|
|
129
|
+
this.tcpClients.delete(clientId);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.tcpServer.on('error', (err) => {
|
|
134
|
+
this._log(`[TCP] Server error: ${err.message}`);
|
|
135
|
+
this.emit('error', err);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.tcpServer.listen(this.tcpPort, '0.0.0.0', () => {
|
|
139
|
+
this._logMinimal(`[TCP] Server listening on port ${this.tcpPort}`);
|
|
140
|
+
this.emit('tcp:listening', { port: this.tcpPort });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Process TCP buffer looking for complete ITV2 packets
|
|
146
|
+
*/
|
|
147
|
+
_processTcpBuffer(buffer, socket, callback) {
|
|
148
|
+
// Look for Integration ID prefix followed by 0x7E
|
|
149
|
+
const idLength = 12; // Integration ID is 12 ASCII chars
|
|
150
|
+
|
|
151
|
+
while (buffer.length > idLength + 2) {
|
|
152
|
+
// Find start marker (0x7E) after integration ID
|
|
153
|
+
const startIdx = buffer.indexOf(0x7E, idLength - 1);
|
|
154
|
+
if (startIdx === -1) {
|
|
155
|
+
// No start marker found, keep last 12 bytes in case ID is split
|
|
156
|
+
callback(buffer.slice(Math.max(0, buffer.length - idLength)));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Find end marker (0x7F)
|
|
161
|
+
const endIdx = buffer.indexOf(0x7F, startIdx + 1);
|
|
162
|
+
if (endIdx === -1) {
|
|
163
|
+
// No end marker yet, wait for more data
|
|
164
|
+
callback(buffer);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Extract complete packet (including ID prefix)
|
|
169
|
+
const packetStart = Math.max(0, startIdx - idLength);
|
|
170
|
+
const packet = buffer.slice(packetStart, endIdx + 1);
|
|
171
|
+
|
|
172
|
+
this._logMinimal(`[TCP] Complete packet (${packet.length} bytes): ${packet.toString('hex')}`);
|
|
173
|
+
|
|
174
|
+
// Process the packet same as UDP
|
|
175
|
+
this._handlePacket(packet, 'tcp', socket);
|
|
176
|
+
|
|
177
|
+
// Continue with remaining buffer
|
|
178
|
+
buffer = buffer.slice(endIdx + 1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
callback(buffer);
|
|
182
|
+
}
|
|
183
|
+
|
|
84
184
|
/**
|
|
85
185
|
* Stop the client and close the session gracefully
|
|
86
186
|
*/
|
|
@@ -105,6 +205,23 @@ export class ITV2Client extends EventEmitter {
|
|
|
105
205
|
this.server = null;
|
|
106
206
|
}
|
|
107
207
|
|
|
208
|
+
// Close TCP server
|
|
209
|
+
if (this.tcpServer) {
|
|
210
|
+
// Close all TCP client connections
|
|
211
|
+
for (const [clientId, socket] of this.tcpClients) {
|
|
212
|
+
socket.destroy();
|
|
213
|
+
}
|
|
214
|
+
this.tcpClients.clear();
|
|
215
|
+
|
|
216
|
+
await new Promise((resolve) => {
|
|
217
|
+
this.tcpServer.close(() => {
|
|
218
|
+
this._log('[Shutdown] TCP server closed');
|
|
219
|
+
resolve();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
this.tcpServer = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
108
225
|
// Clear session and encryption state to prevent stale keys on reconnect
|
|
109
226
|
if (this.session) {
|
|
110
227
|
this.session.disableAes();
|
|
@@ -277,19 +394,29 @@ export class ITV2Client extends EventEmitter {
|
|
|
277
394
|
}
|
|
278
395
|
|
|
279
396
|
_sendPacket(packet) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
397
|
+
// Use the same transport that the last packet came from
|
|
398
|
+
if (this._lastTransport === 'tcp' && this._lastTcpSocket) {
|
|
399
|
+
this._log(`\n[TCP] TX (${packet.length} bytes)`);
|
|
400
|
+
if (this.debug) {
|
|
401
|
+
this._hexDump(packet);
|
|
402
|
+
}
|
|
403
|
+
this._lastTcpSocket.write(packet);
|
|
404
|
+
} else {
|
|
405
|
+
// UDP (default)
|
|
406
|
+
if (!this.panelAddress || !this.panelPort) {
|
|
407
|
+
this._log('[Error] No panel address/port set');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
284
410
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
411
|
+
this._log(`\n[UDP] TX to ${this.panelAddress}:${this.panelPort} (${packet.length} bytes)`);
|
|
412
|
+
if (this.debug) {
|
|
413
|
+
this._hexDump(packet);
|
|
414
|
+
}
|
|
415
|
+
this.server.send(packet, this.panelPort, this.panelAddress);
|
|
288
416
|
}
|
|
289
|
-
this.server.send(packet, this.panelPort, this.panelAddress);
|
|
290
417
|
}
|
|
291
418
|
|
|
292
|
-
_handlePacket(data) {
|
|
419
|
+
_handlePacket(data, transport = 'udp', tcpSocket = null) {
|
|
293
420
|
try {
|
|
294
421
|
if (!this.session) {
|
|
295
422
|
// Create session with appropriate logger based on log level
|
|
@@ -304,8 +431,13 @@ export class ITV2Client extends EventEmitter {
|
|
|
304
431
|
return;
|
|
305
432
|
}
|
|
306
433
|
|
|
307
|
-
//
|
|
308
|
-
this.
|
|
434
|
+
// Store transport info for responses
|
|
435
|
+
this._lastTransport = transport;
|
|
436
|
+
this._lastTcpSocket = tcpSocket;
|
|
437
|
+
|
|
438
|
+
// Verbose logging
|
|
439
|
+
const source = transport === 'tcp' ? 'TCP' : `UDP ${this.panelAddress}:${this.panelPort}`;
|
|
440
|
+
this._log(`\n[${transport.toUpperCase()}] RX from ${source} (${data.length} bytes)`);
|
|
309
441
|
this._hexDump(data);
|
|
310
442
|
this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
|
|
311
443
|
this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
|
|
@@ -379,9 +511,23 @@ export class ITV2Client extends EventEmitter {
|
|
|
379
511
|
case CMD.TIME_DATE_BROADCAST:
|
|
380
512
|
this._handleTimeDateBroadcast(parsed);
|
|
381
513
|
break;
|
|
514
|
+
case CMD.ZONE_STATUS:
|
|
515
|
+
this._handleZoneStatusResponse(parsed);
|
|
516
|
+
break;
|
|
517
|
+
case CMD.PARTITION_STATUS:
|
|
518
|
+
this._handlePartitionStatusResponse(parsed);
|
|
519
|
+
break;
|
|
520
|
+
case CMD.GLOBAL_STATUS:
|
|
521
|
+
this._handleGlobalStatusResponse(parsed);
|
|
522
|
+
break;
|
|
382
523
|
default:
|
|
383
524
|
if (this.handshakeState === 'ESTABLISHED') {
|
|
384
|
-
|
|
525
|
+
const cmdHex = cmd ? '0x' + cmd.toString(16).padStart(4, '0') : 'null';
|
|
526
|
+
const cmdName = CMD_NAMES[cmd] || 'UNKNOWN';
|
|
527
|
+
this._logMinimal(`[Session] Received command ${cmdName} (${cmdHex})`);
|
|
528
|
+
if (parsed.commandData && parsed.commandData.length > 0) {
|
|
529
|
+
this._logMinimal(`[Session] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex')}`);
|
|
530
|
+
}
|
|
385
531
|
this._sendPacket(this.session.buildSimpleAck());
|
|
386
532
|
}
|
|
387
533
|
break;
|
|
@@ -569,10 +715,17 @@ export class ITV2Client extends EventEmitter {
|
|
|
569
715
|
}
|
|
570
716
|
|
|
571
717
|
_handleCommandResponse(parsed) {
|
|
572
|
-
const
|
|
718
|
+
const data = parsed.commandData;
|
|
719
|
+
const responseCode = data?.[0] || 0;
|
|
573
720
|
const appSeqAsEcho = parsed.appSequence;
|
|
574
721
|
|
|
575
|
-
this._log(`[
|
|
722
|
+
this._log(`[Session] Got COMMAND_RESPONSE (app_seq: ${appSeqAsEcho}, response_code: ${responseCode})`);
|
|
723
|
+
|
|
724
|
+
// Log full data in established state for debugging query responses
|
|
725
|
+
if (this.handshakeState === 'ESTABLISHED' && data && data.length > 1) {
|
|
726
|
+
this._logMinimal(`[Command Response] Response code: ${responseCode}, data (${data.length} bytes): ${data.toString('hex')}`);
|
|
727
|
+
this.emit('command:response', { responseCode, appSequence: appSeqAsEcho, data });
|
|
728
|
+
}
|
|
576
729
|
|
|
577
730
|
const ack = this.session.buildSimpleAck();
|
|
578
731
|
this._sendPacket(ack);
|
|
@@ -655,6 +808,75 @@ export class ITV2Client extends EventEmitter {
|
|
|
655
808
|
this._sendPacket(this.session.buildSimpleAck());
|
|
656
809
|
}
|
|
657
810
|
|
|
811
|
+
// ==================== Status Query Response Handlers ====================
|
|
812
|
+
|
|
813
|
+
_handleZoneStatusResponse(parsed) {
|
|
814
|
+
const data = parsed.commandData;
|
|
815
|
+
this._logMinimal(`[Zone Status] Received zone status response`);
|
|
816
|
+
this._log(`[Zone Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
|
|
817
|
+
|
|
818
|
+
if (data && data.length >= 3) {
|
|
819
|
+
// Response format: [ZoneNumber 2B BE][StatusByte 1B][...more zones...]
|
|
820
|
+
const zoneNum = data.readUInt16BE(0);
|
|
821
|
+
const statusByte = data[2];
|
|
822
|
+
|
|
823
|
+
// Update internal state
|
|
824
|
+
const fullStatus = this.eventHandler.handleZoneStatus(zoneNum, statusByte);
|
|
825
|
+
|
|
826
|
+
this._logMinimal(`[Zone Status] Zone ${zoneNum}: ${fullStatus.open ? 'OPEN' : 'CLOSED'} (0x${statusByte.toString(16)})`);
|
|
827
|
+
|
|
828
|
+
// Emit event with full status
|
|
829
|
+
this.emit('zone:statusResponse', zoneNum, fullStatus);
|
|
830
|
+
this.emit('zone:status', zoneNum, fullStatus);
|
|
831
|
+
} else {
|
|
832
|
+
this._log(`[Zone Status] Empty or invalid response`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
_handlePartitionStatusResponse(parsed) {
|
|
839
|
+
const data = parsed.commandData;
|
|
840
|
+
this._logMinimal(`[Partition Status] Received partition status response`);
|
|
841
|
+
this._log(`[Partition Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
|
|
842
|
+
|
|
843
|
+
if (data && data.length >= 3) {
|
|
844
|
+
// Response format: [PartitionNumber 2B BE][StatusBytes...]
|
|
845
|
+
const partitionNum = data.readUInt16BE(0);
|
|
846
|
+
const statusBytes = data.slice(2);
|
|
847
|
+
|
|
848
|
+
this._logMinimal(`[Partition Status] Partition ${partitionNum}: status bytes ${statusBytes.toString('hex')}`);
|
|
849
|
+
|
|
850
|
+
// Emit raw event - let consumers parse the detailed status
|
|
851
|
+
this.emit('partition:statusResponse', partitionNum, statusBytes);
|
|
852
|
+
} else {
|
|
853
|
+
this._log(`[Partition Status] Empty or invalid response`);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
_handleGlobalStatusResponse(parsed) {
|
|
860
|
+
const data = parsed.commandData;
|
|
861
|
+
this._logMinimal(`[Global Status] Received global status response`);
|
|
862
|
+
this._log(`[Global Status] Raw data (${data?.length || 0} bytes): ${data?.toString('hex') || 'none'}`);
|
|
863
|
+
|
|
864
|
+
if (data && data.length > 0) {
|
|
865
|
+
// Emit raw event with full data
|
|
866
|
+
this.emit('global:statusResponse', data);
|
|
867
|
+
|
|
868
|
+
// Log byte breakdown for debugging
|
|
869
|
+
this._log(`[Global Status] Byte breakdown:`);
|
|
870
|
+
for (let i = 0; i < data.length && i < 32; i++) {
|
|
871
|
+
this._log(` Byte ${i}: 0x${data[i].toString(16).padStart(2, '0')} (${data[i]})`);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
this._log(`[Global Status] Empty response`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
this._sendPacket(this.session.buildSimpleAck());
|
|
878
|
+
}
|
|
879
|
+
|
|
658
880
|
// ==================== Utility Methods ====================
|
|
659
881
|
|
|
660
882
|
_handleError(error) {
|