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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.6",
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
- return new Promise((resolve, reject) => {
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.handshakeState = 'WAITING';
79
- if (this.session) {
80
- this.session.disableAes();
81
- this.session = null;
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
- // Register signal handlers (only once)
106
- if (!this._boundShutdown) {
107
- this._boundShutdown = async () => {
108
- await this.stop();
109
- process.exit(0);
110
- };
111
- process.on('SIGINT', this._boundShutdown);
112
- process.on('SIGTERM', this._boundShutdown);
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]