dsc-itv2-client 2.0.6 → 2.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/package.json +1 -1
- package/src/ITV2Client.js +165 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsc-itv2-client",
|
|
3
3
|
"author": "fajitacat",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.9",
|
|
5
5
|
"description": "Reverse engineered DSC ITV2 Protocol Client Library for TL280/TL280E - Monitor and control DSC Neo alarm panels with real-time zone/partition status, arming, and trouble detail",
|
|
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 net from 'net';
|
|
8
|
+
import dgram from 'dgram';
|
|
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';
|
|
@@ -23,12 +24,16 @@ export class ITV2Client extends EventEmitter {
|
|
|
23
24
|
this.accessCode = options.accessCode;
|
|
24
25
|
this.masterCode = options.masterCode || '5555';
|
|
25
26
|
this.port = options.port || 3072; // TCP port (panel connects to us)
|
|
27
|
+
this.udpPort = options.udpPort || null; // UDP port (optional, panel polls us)
|
|
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
32
|
this.tcpServer = null; // TCP server
|
|
31
33
|
this.tcpSocket = null; // Active panel TCP connection
|
|
34
|
+
this.udpSocket = null; // UDP socket
|
|
35
|
+
this._udpRemote = null; // { address, port } of panel over UDP
|
|
36
|
+
this._activeTransport = null; // 'tcp' or 'udp' — whichever the panel connected on
|
|
32
37
|
this.session = null;
|
|
33
38
|
this.eventHandler = new EventHandler();
|
|
34
39
|
this.handshakeState = 'WAITING';
|
|
@@ -48,17 +53,20 @@ export class ITV2Client extends EventEmitter {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
* Start the TCP server and listen for panel connection
|
|
56
|
+
* Start the TCP server (and optional UDP socket) and listen for panel connection
|
|
52
57
|
* The panel initiates a TCP connection to us on port 3072 (default)
|
|
58
|
+
* If udpPort is configured, also listen for UDP datagrams
|
|
53
59
|
*/
|
|
54
60
|
async start() {
|
|
55
|
-
|
|
61
|
+
// Start TCP server
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
56
63
|
this.tcpServer = net.createServer((socket) => {
|
|
57
64
|
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
58
65
|
this._logMinimal(`[TCP] Panel connected: ${clientId}`);
|
|
59
66
|
|
|
60
67
|
this.panelAddress = socket.remoteAddress;
|
|
61
68
|
this.tcpSocket = socket;
|
|
69
|
+
this._activeTransport = 'tcp';
|
|
62
70
|
|
|
63
71
|
let buffer = Buffer.alloc(0);
|
|
64
72
|
|
|
@@ -75,12 +83,15 @@ export class ITV2Client extends EventEmitter {
|
|
|
75
83
|
socket.on('close', () => {
|
|
76
84
|
this._logMinimal(`[TCP] Panel disconnected: ${clientId}`);
|
|
77
85
|
this.tcpSocket = null;
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
this.
|
|
81
|
-
this.session
|
|
86
|
+
if (this._activeTransport === 'tcp') {
|
|
87
|
+
this._activeTransport = null;
|
|
88
|
+
this.handshakeState = 'WAITING';
|
|
89
|
+
if (this.session) {
|
|
90
|
+
this.session.disableAes();
|
|
91
|
+
this.session = null;
|
|
92
|
+
}
|
|
93
|
+
this.emit('session:closed');
|
|
82
94
|
}
|
|
83
|
-
this.emit('session:closed');
|
|
84
95
|
});
|
|
85
96
|
|
|
86
97
|
socket.on('error', (err) => {
|
|
@@ -101,16 +112,60 @@ export class ITV2Client extends EventEmitter {
|
|
|
101
112
|
this.emit('listening', address);
|
|
102
113
|
resolve();
|
|
103
114
|
});
|
|
115
|
+
});
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
// Start UDP socket if configured
|
|
118
|
+
if (this.udpPort) {
|
|
119
|
+
await this._startUdpSocket();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Register signal handlers (only once)
|
|
123
|
+
if (!this._boundShutdown) {
|
|
124
|
+
this._boundShutdown = async () => {
|
|
125
|
+
await this.stop();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
};
|
|
128
|
+
process.on('SIGINT', this._boundShutdown);
|
|
129
|
+
process.on('SIGTERM', this._boundShutdown);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Start UDP socket for panel communication
|
|
135
|
+
* UDP datagrams are self-contained packets (no stream reassembly needed)
|
|
136
|
+
*/
|
|
137
|
+
async _startUdpSocket() {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
this.udpSocket = dgram.createSocket('udp4');
|
|
140
|
+
|
|
141
|
+
this.udpSocket.on('message', (msg, rinfo) => {
|
|
142
|
+
this._log(`[UDP] Received ${msg.length} bytes from ${rinfo.address}:${rinfo.port}`);
|
|
143
|
+
|
|
144
|
+
// Track remote for responses
|
|
145
|
+
this._udpRemote = { address: rinfo.address, port: rinfo.port };
|
|
146
|
+
this.panelAddress = rinfo.address;
|
|
147
|
+
|
|
148
|
+
// If panel is using UDP, mark it as active transport
|
|
149
|
+
if (this._activeTransport !== 'tcp') {
|
|
150
|
+
this._activeTransport = 'udp';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// UDP datagrams are complete packets — process directly
|
|
154
|
+
this._handlePacket(msg);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
this.udpSocket.on('error', (err) => {
|
|
158
|
+
this._log(`[Error] UDP socket error: ${err.message}`);
|
|
159
|
+
this.emit('error', err);
|
|
160
|
+
reject(err);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
this.udpSocket.bind(this.udpPort, '0.0.0.0', () => {
|
|
164
|
+
const address = this.udpSocket.address();
|
|
165
|
+
this._logMinimal(`[UDP] Listening on port ${address.port}`);
|
|
166
|
+
this.emit('listening:udp', address);
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
114
169
|
});
|
|
115
170
|
}
|
|
116
171
|
|
|
@@ -180,6 +235,18 @@ export class ITV2Client extends EventEmitter {
|
|
|
180
235
|
this.tcpServer = null;
|
|
181
236
|
}
|
|
182
237
|
|
|
238
|
+
// Close UDP socket
|
|
239
|
+
if (this.udpSocket) {
|
|
240
|
+
await new Promise((resolve) => {
|
|
241
|
+
this.udpSocket.close(() => {
|
|
242
|
+
this._log('[Shutdown] UDP socket closed');
|
|
243
|
+
resolve();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
this.udpSocket = null;
|
|
247
|
+
this._udpRemote = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
183
250
|
// Clear heartbeat
|
|
184
251
|
if (this._heartbeatInterval) {
|
|
185
252
|
clearInterval(this._heartbeatInterval);
|
|
@@ -205,6 +272,7 @@ export class ITV2Client extends EventEmitter {
|
|
|
205
272
|
}
|
|
206
273
|
this.handshakeState = 'WAITING';
|
|
207
274
|
this.panelAddress = null;
|
|
275
|
+
this._activeTransport = null;
|
|
208
276
|
this.detectedEncryptionType = null;
|
|
209
277
|
this._panelOpenSessionData = null;
|
|
210
278
|
|
|
@@ -627,6 +695,13 @@ export class ITV2Client extends EventEmitter {
|
|
|
627
695
|
}
|
|
628
696
|
|
|
629
697
|
_sendPacket(packet) {
|
|
698
|
+
if (this._activeTransport === 'udp' && this.udpSocket && this._udpRemote) {
|
|
699
|
+
this._log(`[UDP] TX ${packet.length} bytes`);
|
|
700
|
+
this._hexDump(packet);
|
|
701
|
+
this.udpSocket.send(packet, this._udpRemote.port, this._udpRemote.address);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
630
705
|
if (!this.tcpSocket || this.tcpSocket.destroyed) {
|
|
631
706
|
this._logMinimal('[Error] No panel connection');
|
|
632
707
|
return;
|
|
@@ -654,9 +729,11 @@ export class ITV2Client extends EventEmitter {
|
|
|
654
729
|
this._log(`[Packet] Integration ID: ${parsed.integrationId}`);
|
|
655
730
|
this._log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
|
|
656
731
|
this._log(`[Packet] Command: ${CMD_NAMES[parsed.command] || parsed.command}`);
|
|
732
|
+
|
|
657
733
|
if (parsed.appSequence !== null) {
|
|
658
734
|
this._log(`[Packet] App Seq: ${parsed.appSequence}`);
|
|
659
735
|
}
|
|
736
|
+
|
|
660
737
|
if (parsed.commandData) {
|
|
661
738
|
this._log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ')}`);
|
|
662
739
|
}
|
|
@@ -758,6 +835,9 @@ export class ITV2Client extends EventEmitter {
|
|
|
758
835
|
case CMD.SINGLE_ZONE_BYPASS_STATUS:
|
|
759
836
|
this._handleZoneBypassNotification(parsed);
|
|
760
837
|
break;
|
|
838
|
+
case CMD.ZONE_ALARM_STATUS:
|
|
839
|
+
this._handleZoneAlarmStatus(parsed);
|
|
840
|
+
break;
|
|
761
841
|
case CMD.MULTIPLE_MESSAGE:
|
|
762
842
|
this._handleMultipleMessagePacket(parsed);
|
|
763
843
|
break;
|
|
@@ -1480,6 +1560,75 @@ export class ITV2Client extends EventEmitter {
|
|
|
1480
1560
|
this._ack();
|
|
1481
1561
|
}
|
|
1482
1562
|
|
|
1563
|
+
_handleZoneAlarmStatus(parsed) {
|
|
1564
|
+
// 0x0840 ModuleStatus_Zone_Alarm_Status (from decompiled SDK):
|
|
1565
|
+
// [Partition CompactInt][Count CompactInt][repeated: Zone CompactInt, AlarmType 1B, AlarmState 1B]
|
|
1566
|
+
//
|
|
1567
|
+
// AlarmType: 1=Unknown, 2=Burglary, 3=24HR_Supervisory, 4=Fire, 5=Fire_Supervisory,
|
|
1568
|
+
// 6=CO, 7=Gas, 8=HighTemp, 9=LowTemp, 10=Medical, 11=Panic, 12=Waterflow,
|
|
1569
|
+
// 13=Water_Leakage, 14=Pendant, 15=Tamper, 16=RF_Jam, 17=Hardware_Fault,
|
|
1570
|
+
// 18=Duress, 19=Personal_Emergency, 20=Holdup, 21=Sprinkler
|
|
1571
|
+
// AlarmState: bit 0 = 0 → NotInAlarm, bit 0 = 1 → InAlarm
|
|
1572
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1573
|
+
if (!fullPayload || fullPayload.length < 6) {
|
|
1574
|
+
this._ack();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const ALARM_TYPES = {
|
|
1579
|
+
1: 'Unknown', 2: 'Burglary', 3: '24HR_Supervisory', 4: 'Fire',
|
|
1580
|
+
5: 'Fire_Supervisory', 6: 'CO', 7: 'Gas', 8: 'HighTemp',
|
|
1581
|
+
9: 'LowTemp', 10: 'Medical', 11: 'Panic', 12: 'Waterflow',
|
|
1582
|
+
13: 'Water_Leakage', 14: 'Pendant', 15: 'Tamper', 16: 'RF_Jam',
|
|
1583
|
+
17: 'Hardware_Fault', 18: 'Duress', 19: 'Personal_Emergency',
|
|
1584
|
+
20: 'Holdup', 21: 'Sprinkler',
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
try {
|
|
1588
|
+
let offset = 0;
|
|
1589
|
+
const partition = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1590
|
+
offset += partition.bytesRead;
|
|
1591
|
+
|
|
1592
|
+
const count = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1593
|
+
offset += count.bytesRead;
|
|
1594
|
+
|
|
1595
|
+
while (offset < fullPayload.length) {
|
|
1596
|
+
const zone = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1597
|
+
offset += zone.bytesRead;
|
|
1598
|
+
if (offset >= fullPayload.length) break;
|
|
1599
|
+
const alarmType = fullPayload[offset++];
|
|
1600
|
+
if (offset >= fullPayload.length) break;
|
|
1601
|
+
const alarmState = fullPayload[offset++];
|
|
1602
|
+
|
|
1603
|
+
const inAlarm = !!(alarmState & 0x01);
|
|
1604
|
+
const alarmTypeName = ALARM_TYPES[alarmType] || `Unknown(${alarmType})`;
|
|
1605
|
+
|
|
1606
|
+
this._logMinimal(`[Zone ${zone.value}] Alarm: ${inAlarm ? 'ACTIVE' : 'CLEARED'} (${alarmTypeName}, partition ${partition.value})`);
|
|
1607
|
+
|
|
1608
|
+
const alarmData = {
|
|
1609
|
+
zone: zone.value,
|
|
1610
|
+
partition: partition.value,
|
|
1611
|
+
alarmType,
|
|
1612
|
+
alarmTypeName,
|
|
1613
|
+
inAlarm,
|
|
1614
|
+
timestamp: new Date(),
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
if (inAlarm) {
|
|
1618
|
+
this.eventHandler.handleZoneAlarm(zone.value, alarmTypeName);
|
|
1619
|
+
this.emit('zone:alarm', alarmData);
|
|
1620
|
+
this.emit('partition:alarm', alarmData);
|
|
1621
|
+
} else {
|
|
1622
|
+
this.emit('zone:alarm:restored', alarmData);
|
|
1623
|
+
this.emit('partition:alarm:restored', alarmData);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
this._log(`[Zone Alarm Status] Parse error: ${e.message}`);
|
|
1628
|
+
}
|
|
1629
|
+
this._ack();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1483
1632
|
_handleExitDelayNotification(parsed) {
|
|
1484
1633
|
// 0x0230 NotificationExitDelay (from neohub):
|
|
1485
1634
|
// [CompactInt:Partition][DelayFlags:1B][CompactInt:DurationInSeconds]
|