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,1033 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSC TL280R ITV2 Protocol Server
|
|
3
|
+
*
|
|
4
|
+
* This server handles the ITV2 protocol handshake and session management
|
|
5
|
+
* for DSC alarm panels using TL280R communicators.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import dgram from 'dgram';
|
|
9
|
+
import readline from 'readline';
|
|
10
|
+
import { ITv2Session, CMD, CMD_NAMES } from '../itv2-session.js';
|
|
11
|
+
import { parseType1Initializer, type2InitializerTransform } from '../itv2-crypto.js';
|
|
12
|
+
import { EventHandler } from '../event-handler.js';
|
|
13
|
+
import { parseZoneStatus, formatZoneStatus, parsePartitionStatus, formatPartitionStatus, parseCommandResponse, parseCommandError } from '../response-parsers.js';
|
|
14
|
+
|
|
15
|
+
// Configuration
|
|
16
|
+
const INTEGRATION_ID = process.env.INTEGRATION_ID || '123123123123';
|
|
17
|
+
const ACCESS_CODE = process.env.ACCESS_CODE || '12345678';
|
|
18
|
+
const MASTER_CODE = process.env.MASTER_CODE || '5555';
|
|
19
|
+
const UDP_PORT = parseInt(process.env.UDP_PORT || '3073', 10);
|
|
20
|
+
|
|
21
|
+
// Session tracking
|
|
22
|
+
let session = null;
|
|
23
|
+
let panelAddress = null;
|
|
24
|
+
let panelPort = null;
|
|
25
|
+
let detectedEncryptionType = null; // Set during handshake: 1 or 2
|
|
26
|
+
let handshakeState = 'WAITING';
|
|
27
|
+
let testCommandSent = false;
|
|
28
|
+
|
|
29
|
+
// Event Handler for zone/partition state management
|
|
30
|
+
const eventHandler = new EventHandler(log);
|
|
31
|
+
|
|
32
|
+
// Register event listeners
|
|
33
|
+
eventHandler.on('zone:status', (status) => {
|
|
34
|
+
console.log(`\n[EVENT] Zone ${status.zoneNumber} status update: ${status.open ? 'OPEN' : 'CLOSED'}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
eventHandler.on('zone:alarm', (alarm) => {
|
|
38
|
+
console.log(`\n[EVENT] *** ZONE ${alarm.zoneNumber} ALARM! ***`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
eventHandler.on('partition:status', (status) => {
|
|
42
|
+
console.log(`\n[EVENT] Partition ${status.partitionNumber} status: ${status.armedStateName}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
eventHandler.on('partition:change', (change) => {
|
|
46
|
+
console.log(`\n[EVENT] Partition ${change.partition} changed: ${change.previousState || 'UNKNOWN'} -> ${change.newState}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// UDP Server
|
|
50
|
+
const server = dgram.createSocket('udp4');
|
|
51
|
+
|
|
52
|
+
function log(msg) {
|
|
53
|
+
console.log(`[${new Date().toISOString()}] ${msg}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hexDump(buffer, prefix = '') {
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (let i = 0; i < buffer.length; i += 16) {
|
|
59
|
+
const slice = buffer.slice(i, Math.min(i + 16, buffer.length));
|
|
60
|
+
const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
|
|
61
|
+
const ascii = Array.from(slice).map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.').join('');
|
|
62
|
+
lines.push(`${prefix}${i.toString(16).padStart(4, '0')} ${hex.padEnd(48)} |${ascii}|`);
|
|
63
|
+
}
|
|
64
|
+
console.log(lines.join('\n'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sendPacket(packet) {
|
|
68
|
+
if (!panelAddress || !panelPort) {
|
|
69
|
+
log('[Error] No panel address/port set');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
log(`\n[UDP] TX to ${panelAddress}:${panelPort} (${packet.length} bytes)`);
|
|
73
|
+
hexDump(packet);
|
|
74
|
+
server.send(packet, panelPort, panelAddress);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sendTestZoneQuery() {
|
|
78
|
+
if (testCommandSent) return;
|
|
79
|
+
testCommandSent = true;
|
|
80
|
+
|
|
81
|
+
log('\n[Test] Session is stable, authenticating first...');
|
|
82
|
+
const authPayload = Buffer.from(MASTER_CODE.padStart(6, '0').slice(0, 6), 'ascii');
|
|
83
|
+
const authPacket = session.buildCommand(CMD.ACCESS_LEVEL_ENTER, authPayload);
|
|
84
|
+
sendPacket(authPacket);
|
|
85
|
+
|
|
86
|
+
// Wait for authentication to complete, then query system info
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
log('\n[Test] Sending SYSTEM_CAPABILITIES query...');
|
|
89
|
+
const capQuery = session.buildSystemCapabilitiesRequest();
|
|
90
|
+
sendPacket(capQuery);
|
|
91
|
+
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
log('\n[Test] Sending SOFTWARE_VERSION query...');
|
|
94
|
+
const verQuery = session.buildCommand(CMD.SOFTWARE_VERSION);
|
|
95
|
+
sendPacket(verQuery);
|
|
96
|
+
}, 2000);
|
|
97
|
+
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
log('\n[Test] Sending PANEL_STATUS query...');
|
|
100
|
+
const panelQuery = session.buildCommand(CMD.PANEL_STATUS);
|
|
101
|
+
sendPacket(panelQuery);
|
|
102
|
+
}, 4000);
|
|
103
|
+
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
log('\n[Test] Sending BUFFER_SIZES query...');
|
|
106
|
+
const bufferQuery = session.buildCommand(CMD.BUFFER_SIZES);
|
|
107
|
+
sendPacket(bufferQuery);
|
|
108
|
+
}, 6000);
|
|
109
|
+
}, 2000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleSystemResponse(cmdName, parsed) {
|
|
113
|
+
const data = parsed.commandData;
|
|
114
|
+
log(`\n[${cmdName} Response] Received data`);
|
|
115
|
+
|
|
116
|
+
console.log(`\n========================================`);
|
|
117
|
+
console.log(`${cmdName} RESPONSE`);
|
|
118
|
+
console.log(`========================================`);
|
|
119
|
+
if (data && data.length > 0) {
|
|
120
|
+
console.log(`Data (${data.length} bytes):`);
|
|
121
|
+
console.log(` HEX: ${data.toString('hex').toUpperCase()}`);
|
|
122
|
+
console.log(` ASCII: ${Array.from(data).map(b => (b >= 32 && b <= 126) ? String.fromCharCode(b) : '.').join('')}`);
|
|
123
|
+
console.log(` Bytes: [${Array.from(data).join(', ')}]`);
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`No data returned`);
|
|
126
|
+
}
|
|
127
|
+
console.log(`========================================\n`);
|
|
128
|
+
|
|
129
|
+
// Send ACK
|
|
130
|
+
sendPacket(session.buildSimpleAck());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleGlobalStatusResponse(parsed) {
|
|
134
|
+
const data = parsed.commandData;
|
|
135
|
+
log(`\n[Global Status] Received global status data`);
|
|
136
|
+
|
|
137
|
+
console.log(`\n========================================`);
|
|
138
|
+
console.log(`GLOBAL STATUS RESPONSE`);
|
|
139
|
+
console.log(`========================================`);
|
|
140
|
+
|
|
141
|
+
if (data && data.length > 0) {
|
|
142
|
+
console.log(`Raw data (${data.length} bytes): ${data.toString('hex').toUpperCase()}`);
|
|
143
|
+
|
|
144
|
+
// Global status may contain partition and zone summary info
|
|
145
|
+
// Format depends on what the panel sends
|
|
146
|
+
// For now, display raw hex and let user interpret
|
|
147
|
+
console.log(`\nData breakdown:`);
|
|
148
|
+
for (let i = 0; i < data.length; i++) {
|
|
149
|
+
console.log(` Byte ${i}: 0x${data[i].toString(16).padStart(2, '0').toUpperCase()} (${data[i]})`);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
console.log(`No data returned`);
|
|
153
|
+
}
|
|
154
|
+
console.log(`========================================\n`);
|
|
155
|
+
|
|
156
|
+
sendPacket(session.buildSimpleAck());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleZoneStatusResponse(parsed) {
|
|
160
|
+
const data = parsed.commandData;
|
|
161
|
+
log(`\n[Zone Status] Received zone status data`);
|
|
162
|
+
|
|
163
|
+
if (data && data.length >= 3) {
|
|
164
|
+
const zoneNum = data.readUInt16BE(0);
|
|
165
|
+
const statusByte = data[2];
|
|
166
|
+
|
|
167
|
+
// Parse using new parser
|
|
168
|
+
const status = parseZoneStatus(statusByte);
|
|
169
|
+
status.zoneNumber = zoneNum;
|
|
170
|
+
|
|
171
|
+
// Also update EventHandler for internal tracking
|
|
172
|
+
eventHandler.handleZoneStatus(zoneNum, statusByte);
|
|
173
|
+
|
|
174
|
+
console.log(`\n========================================`);
|
|
175
|
+
console.log(`ZONE STATUS for Zone ${zoneNum}`);
|
|
176
|
+
console.log(`========================================`);
|
|
177
|
+
console.log(`Status Byte: 0x${statusByte.toString(16).padStart(2, '0').toUpperCase()}`);
|
|
178
|
+
console.log(` Open: ${status.open ? 'YES' : 'NO'}`);
|
|
179
|
+
console.log(` Tamper: ${status.tamper ? 'YES' : 'NO'}`);
|
|
180
|
+
console.log(` Fault: ${status.fault ? 'YES' : 'NO'}`);
|
|
181
|
+
console.log(` Low Battery: ${status.lowBattery ? 'YES' : 'NO'}`);
|
|
182
|
+
console.log(` Delinquency: ${status.delinquency ? 'YES' : 'NO'}`);
|
|
183
|
+
console.log(` Alarm: ${status.alarm ? 'YES' : 'NO'}`);
|
|
184
|
+
console.log(` Alarm Memory: ${status.alarmInMemory ? 'YES' : 'NO'}`);
|
|
185
|
+
console.log(` Bypassed: ${status.bypassed ? 'YES' : 'NO'}`);
|
|
186
|
+
console.log(`\n${formatZoneStatus(status)}`);
|
|
187
|
+
console.log(`========================================\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Send ACK
|
|
191
|
+
sendPacket(session.buildSimpleAck());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handlePartitionStatusResponse(parsed) {
|
|
195
|
+
const data = parsed.commandData;
|
|
196
|
+
log(`\n[Partition Status] Received partition status data`);
|
|
197
|
+
|
|
198
|
+
if (data && data.length >= 3) {
|
|
199
|
+
const partitionNum = data.readUInt16BE(0);
|
|
200
|
+
const statusBytes = data.slice(2);
|
|
201
|
+
|
|
202
|
+
// Parse using new parser
|
|
203
|
+
const status = parsePartitionStatus(statusBytes);
|
|
204
|
+
status.partitionNumber = partitionNum;
|
|
205
|
+
|
|
206
|
+
console.log(`\n========================================`);
|
|
207
|
+
console.log(`PARTITION STATUS for Partition ${partitionNum}`);
|
|
208
|
+
console.log(`========================================`);
|
|
209
|
+
console.log(`Status Bytes: ${statusBytes.toString('hex').toUpperCase()}`);
|
|
210
|
+
console.log(` Armed State: ${status.armedState}`);
|
|
211
|
+
console.log(` Ready: ${status.ready ? 'YES' : 'NO'}`);
|
|
212
|
+
console.log(` Alarm: ${status.alarm ? 'YES' : 'NO'}`);
|
|
213
|
+
console.log(` Trouble: ${status.trouble ? 'YES' : 'NO'}`);
|
|
214
|
+
console.log(` Bypass Active: ${status.bypass ? 'YES' : 'NO'}`);
|
|
215
|
+
console.log(` Busy: ${status.busy ? 'YES' : 'NO'}`);
|
|
216
|
+
console.log(` Alarm Memory: ${status.alarmMemory ? 'YES' : 'NO'}`);
|
|
217
|
+
console.log(` Audible Bell: ${status.audibleBell ? 'YES' : 'NO'}`);
|
|
218
|
+
console.log(` Buzzer: ${status.buzzer ? 'YES' : 'NO'}`);
|
|
219
|
+
console.log(`\n${formatPartitionStatus(status)}`);
|
|
220
|
+
console.log(`========================================\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Send ACK
|
|
224
|
+
sendPacket(session.buildSimpleAck());
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleZoneAlarmNotification(parsed) {
|
|
228
|
+
const data = parsed.commandData;
|
|
229
|
+
log(`\n[Zone Alarm] Received zone alarm notification`);
|
|
230
|
+
|
|
231
|
+
if (data && data.length >= 3) {
|
|
232
|
+
const zoneNum = data.readUInt16BE(0);
|
|
233
|
+
const alarmType = data[2];
|
|
234
|
+
|
|
235
|
+
eventHandler.handleZoneAlarm(zoneNum, alarmType);
|
|
236
|
+
|
|
237
|
+
console.log(`\n========================================`);
|
|
238
|
+
console.log(`*** ZONE ALARM ***`);
|
|
239
|
+
console.log(`Zone: ${zoneNum}`);
|
|
240
|
+
console.log(`Alarm Type: ${alarmType}`);
|
|
241
|
+
console.log(`========================================\n`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
sendPacket(session.buildSimpleAck());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function handleLifestyleZoneNotification(parsed) {
|
|
248
|
+
const data = parsed.commandData;
|
|
249
|
+
|
|
250
|
+
if (data && data.length >= 2) {
|
|
251
|
+
const zoneNum = data[0]; // Zone number (single byte for 0x0210)
|
|
252
|
+
const status = data[1]; // 0=Closed, 1=Closed, 2=Open (based on SDK output)
|
|
253
|
+
|
|
254
|
+
const statusText = status === 0 ? 'CLOSED' : (status === 1 ? 'CLOSED' : 'OPEN');
|
|
255
|
+
|
|
256
|
+
console.log(`\n[Zone ${zoneNum}] ${statusText}`);
|
|
257
|
+
log(`[Lifestyle Zone] Zone ${zoneNum}: ${statusText} (status byte: ${status})`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
sendPacket(session.buildSimpleAck());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function handleTimeDateNotification(parsed) {
|
|
264
|
+
const data = parsed.commandData;
|
|
265
|
+
|
|
266
|
+
if (data && data.length >= 5) {
|
|
267
|
+
// Parse date/time from payload
|
|
268
|
+
log(`[Time/Date] Received time/date broadcast: ${data.toString('hex')}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
sendPacket(session.buildSimpleAck());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handlePartitionArmingNotification(parsed) {
|
|
275
|
+
const data = parsed.commandData;
|
|
276
|
+
log(`\n[Partition Arming] Received partition arming notification`);
|
|
277
|
+
|
|
278
|
+
if (data && data.length >= 3) {
|
|
279
|
+
const partitionNum = data.readUInt16BE(0);
|
|
280
|
+
const armedState = data[2];
|
|
281
|
+
|
|
282
|
+
const status = eventHandler.handlePartitionArming(partitionNum, armedState);
|
|
283
|
+
|
|
284
|
+
console.log(`\n========================================`);
|
|
285
|
+
console.log(`PARTITION ARMING NOTIFICATION`);
|
|
286
|
+
console.log(`Partition: ${partitionNum}`);
|
|
287
|
+
console.log(`State: ${status.armedStateName}`);
|
|
288
|
+
console.log(`Armed: ${status.isArmed ? 'YES' : 'NO'}`);
|
|
289
|
+
console.log(`In Delay: ${status.inDelay ? 'YES' : 'NO'}`);
|
|
290
|
+
console.log(`========================================\n`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
sendPacket(session.buildSimpleAck());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function handlePartitionReadyNotification(parsed) {
|
|
297
|
+
const data = parsed.commandData;
|
|
298
|
+
log(`\n[Partition Ready] Received partition ready notification`);
|
|
299
|
+
|
|
300
|
+
if (data && data.length >= 3) {
|
|
301
|
+
const partitionNum = data.readUInt16BE(0);
|
|
302
|
+
const isReady = data[2] !== 0;
|
|
303
|
+
|
|
304
|
+
eventHandler.handlePartitionReady(partitionNum, isReady);
|
|
305
|
+
|
|
306
|
+
console.log(`\n========================================`);
|
|
307
|
+
console.log(`PARTITION READY NOTIFICATION`);
|
|
308
|
+
console.log(`Partition: ${partitionNum}`);
|
|
309
|
+
console.log(`Ready: ${isReady ? 'YES' : 'NO'}`);
|
|
310
|
+
console.log(`========================================\n`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
sendPacket(session.buildSimpleAck());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function handlePartitionTroubleNotification(parsed) {
|
|
317
|
+
const data = parsed.commandData;
|
|
318
|
+
log(`\n[Partition Trouble] Received partition trouble notification`);
|
|
319
|
+
|
|
320
|
+
if (data && data.length >= 3) {
|
|
321
|
+
const partitionNum = data.readUInt16BE(0);
|
|
322
|
+
const troubleFlags = data[2];
|
|
323
|
+
|
|
324
|
+
eventHandler.handlePartitionTrouble(partitionNum, troubleFlags);
|
|
325
|
+
|
|
326
|
+
console.log(`\n========================================`);
|
|
327
|
+
console.log(`PARTITION TROUBLE NOTIFICATION`);
|
|
328
|
+
console.log(`Partition: ${partitionNum}`);
|
|
329
|
+
console.log(`Trouble Flags: 0x${troubleFlags.toString(16).padStart(2, '0')}`);
|
|
330
|
+
console.log(`========================================\n`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
sendPacket(session.buildSimpleAck());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function handleOpenSession(parsed) {
|
|
337
|
+
// OPEN_SESSION does NOT have an app sequence - the "appSequence" field is actually the device type
|
|
338
|
+
// Re-parse: [sender][receiver][cmd 2B][device_type][payload...]
|
|
339
|
+
// parsed.appSequence is actually the device type (first payload byte)
|
|
340
|
+
// parsed.commandData starts at the second payload byte
|
|
341
|
+
|
|
342
|
+
const deviceType = parsed.appSequence; // This is actually the device type, not app seq
|
|
343
|
+
const data = parsed.commandData;
|
|
344
|
+
|
|
345
|
+
if (deviceType !== undefined && data && data.length >= 13) {
|
|
346
|
+
// Reconstruct full payload: [deviceType] + [rest of data]
|
|
347
|
+
const fullPayload = Buffer.concat([Buffer.from([deviceType]), data]);
|
|
348
|
+
|
|
349
|
+
const deviceId = fullPayload.slice(1, 3).toString('hex');
|
|
350
|
+
const firmware = fullPayload.slice(3, 5).toString('hex');
|
|
351
|
+
const protocol = fullPayload.slice(5, 7).toString('hex');
|
|
352
|
+
const txBuffer = fullPayload.readUInt16BE(7);
|
|
353
|
+
const rxBuffer = fullPayload.readUInt16BE(9);
|
|
354
|
+
const encType = fullPayload[13];
|
|
355
|
+
|
|
356
|
+
log(`[Session] OPEN_SESSION received:`);
|
|
357
|
+
log(` Device Type: ${deviceType}`);
|
|
358
|
+
log(` Device ID: ${deviceId}`);
|
|
359
|
+
log(` Firmware: ${firmware}`);
|
|
360
|
+
log(` Protocol: ${protocol}`);
|
|
361
|
+
log(` TX Buffer: ${txBuffer}`);
|
|
362
|
+
log(` RX Buffer: ${rxBuffer}`);
|
|
363
|
+
log(` Encryption Type: ${encType}`);
|
|
364
|
+
|
|
365
|
+
// Reset session if panel is starting a new handshake
|
|
366
|
+
if (handshakeState !== 'WAITING' && handshakeState !== 'SENT_CMD_RESPONSE_1') {
|
|
367
|
+
log(`[Session] Panel restarting handshake (was in state ${handshakeState}), resetting session`);
|
|
368
|
+
session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
|
|
369
|
+
testCommandSent = false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
session.remoteOpenSession = fullPayload;
|
|
373
|
+
log(`[Handshake] Panel advertises encryption Type ${encType}`);
|
|
374
|
+
|
|
375
|
+
// Respond with COMMAND_RESPONSE success
|
|
376
|
+
// SDK shows: COMMAND_RESPONSE echoes the first byte (device type) from OPEN_SESSION
|
|
377
|
+
log(`[Handshake] Sending COMMAND_RESPONSE success (echoing device type ${deviceType})`);
|
|
378
|
+
const response = session.buildCommandResponseWithAppSeq(deviceType, 0x00);
|
|
379
|
+
sendPacket(response);
|
|
380
|
+
handshakeState = 'SENT_CMD_RESPONSE_1';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function handleRequestAccess(parsed) {
|
|
385
|
+
const data = parsed.commandData;
|
|
386
|
+
log(`[Handshake] Got panel's REQUEST_ACCESS`);
|
|
387
|
+
|
|
388
|
+
// If already established, just ACK and ignore the retry
|
|
389
|
+
if (handshakeState === 'ESTABLISHED') {
|
|
390
|
+
log(`[Handshake] Session already established, ACKing panel's REQUEST_ACCESS retry`);
|
|
391
|
+
sendPacket(session.buildSimpleAck());
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (data && data.length > 0) {
|
|
396
|
+
const initializerLength = data[0];
|
|
397
|
+
const initializer = data.slice(1);
|
|
398
|
+
|
|
399
|
+
log(`[Handshake] Processing panel's REQUEST_ACCESS (panel goes first)`);
|
|
400
|
+
log(`[Handshake] Panel initializer (${initializer.length} bytes): ${initializer.toString('hex')}`);
|
|
401
|
+
|
|
402
|
+
if (initializer.length === 48) {
|
|
403
|
+
// Type 1 encryption
|
|
404
|
+
detectedEncryptionType = 1;
|
|
405
|
+
log(`[Handshake] Encryption Type 1 (based on initializer length)`);
|
|
406
|
+
log(`[Handshake] Type 1: Parsing 48-byte initializer from panel`);
|
|
407
|
+
log(`[Handshake] Using access code: ${ACCESS_CODE}, integration ID: ${INTEGRATION_ID}`);
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const sendKey = parseType1Initializer(ACCESS_CODE, initializer, INTEGRATION_ID);
|
|
411
|
+
log(`[Handshake] Type 1 SEND key derived: ${sendKey.toString('hex')}`);
|
|
412
|
+
|
|
413
|
+
// Store the SEND key for later (after handshake completes)
|
|
414
|
+
// Based on SDK trace: ALL handshake frames are PLAINTEXT
|
|
415
|
+
session.derivedSendKey = sendKey;
|
|
416
|
+
log(`[Handshake] SEND key stored (will enable after handshake): ${sendKey.toString('hex')}`);
|
|
417
|
+
|
|
418
|
+
// Send PLAINTEXT COMMAND_RESPONSE (echoing the app sequence from REQUEST_ACCESS)
|
|
419
|
+
// SDK trace shows: 08 03 02 05 02 66 00 9B A9 (plaintext)
|
|
420
|
+
log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
|
|
421
|
+
const appSeq = parsed.appSequence; // Echo the app sequence from the request
|
|
422
|
+
const response = session.buildCommandResponseWithAppSeq(appSeq, 0x00);
|
|
423
|
+
sendPacket(response);
|
|
424
|
+
|
|
425
|
+
// Wait for panel to ACK our COMMAND_RESPONSE before sending our REQUEST_ACCESS
|
|
426
|
+
// Sending back-to-back was causing the panel to reset
|
|
427
|
+
handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
|
|
428
|
+
log(`[Handshake] Waiting for ACK before sending our REQUEST_ACCESS`);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log(`[Handshake] Error parsing Type 1 initializer: ${err.message}`);
|
|
431
|
+
}
|
|
432
|
+
} else if (initializer.length === 16) {
|
|
433
|
+
// Type 2 encryption
|
|
434
|
+
detectedEncryptionType = 2;
|
|
435
|
+
log(`[Handshake] Encryption Type 2 (based on initializer length)`);
|
|
436
|
+
log(`[Handshake] Type 2: Parsing 16-byte initializer from panel`);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
// Expand access code to 32 hex if it's 8 digits
|
|
440
|
+
let accessCode32 = ACCESS_CODE;
|
|
441
|
+
if (accessCode32.length === 8) {
|
|
442
|
+
accessCode32 = accessCode32 + accessCode32 + accessCode32 + accessCode32;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const sendKey = type2InitializerTransform(accessCode32, initializer);
|
|
446
|
+
log(`[Handshake] Type 2 SEND key derived: ${sendKey.toString('hex')}`);
|
|
447
|
+
|
|
448
|
+
session.derivedSendKey = sendKey;
|
|
449
|
+
log(`[Handshake] SEND key stored (will enable after handshake): ${sendKey.toString('hex')}`);
|
|
450
|
+
|
|
451
|
+
// Send PLAINTEXT COMMAND_RESPONSE
|
|
452
|
+
log(`[Handshake] Sending PLAINTEXT COMMAND_RESPONSE to panel's REQUEST_ACCESS`);
|
|
453
|
+
const appSeq = parsed.appSequence;
|
|
454
|
+
const response = session.buildCommandResponseWithAppSeq(appSeq, 0x00);
|
|
455
|
+
sendPacket(response);
|
|
456
|
+
|
|
457
|
+
handshakeState = 'WAITING_TO_SEND_REQUEST_ACCESS';
|
|
458
|
+
log(`[Handshake] Waiting for ACK before sending our REQUEST_ACCESS`);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log(`[Handshake] Error parsing Type 2 initializer: ${err.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function handleSimpleAck(parsed) {
|
|
467
|
+
const ackSeq = parsed.receiverSequence;
|
|
468
|
+
log(`[Handshake] Got ACK ${ackSeq}, state=${handshakeState}`);
|
|
469
|
+
|
|
470
|
+
if (handshakeState === 'SENT_CMD_RESPONSE_1') {
|
|
471
|
+
// Send our OPEN_SESSION (PLAINTEXT)
|
|
472
|
+
log(`[Handshake] Got ACK 1, sending our OPEN_SESSION`);
|
|
473
|
+
// SDK trace shows OPEN_SESSION payload does NOT have AppSeq prefix for server->panel direction
|
|
474
|
+
// SDK packet: 15 02 00 06 0A 01 80 00 00 02 01 02 41 02 00 02 00 00 01 01 A7 4B
|
|
475
|
+
// Payload starts with DeviceType=0x01, NOT ApplicationSequenceNumber
|
|
476
|
+
const openSessionPayload = Buffer.from([
|
|
477
|
+
0x01, // Device Type (0x01 = server, vs 0x02 for panel)
|
|
478
|
+
0x80, 0x00, // Device ID (our ID)
|
|
479
|
+
0x00, 0x02, // Firmware version (0.2)
|
|
480
|
+
0x01, 0x02, // Protocol version
|
|
481
|
+
0x41, 0x02, // TX Buffer (little-endian 0x0241 = 577)
|
|
482
|
+
0x00, 0x02, // RX Buffer (little-endian 0x0200 = 512)
|
|
483
|
+
0x00, 0x00, // Future/reserved
|
|
484
|
+
0x01, // Encryption Type (1 = Type 1)
|
|
485
|
+
0x01, // Extra byte (matches SDK exactly)
|
|
486
|
+
]);
|
|
487
|
+
const packet = session.buildOpenSessionResponse(openSessionPayload);
|
|
488
|
+
sendPacket(packet);
|
|
489
|
+
handshakeState = 'SENT_OPEN_SESSION';
|
|
490
|
+
} else if (handshakeState === 'WAITING_TO_SEND_REQUEST_ACCESS') {
|
|
491
|
+
// Panel ACKed our COMMAND_RESPONSE to their REQUEST_ACCESS
|
|
492
|
+
// Now send our REQUEST_ACCESS
|
|
493
|
+
log(`[Handshake] Got ACK, now sending our REQUEST_ACCESS`);
|
|
494
|
+
// Enable encryption with the SEND key derived from panel's initializer
|
|
495
|
+
session.sendAesActive = true;
|
|
496
|
+
session.sendAesKey = session.derivedSendKey;
|
|
497
|
+
log(`[Handshake] Encrypting REQUEST_ACCESS with SEND key: ${session.sendAesKey?.toString('hex')}`);
|
|
498
|
+
|
|
499
|
+
// Build REQUEST_ACCESS based on detected encryption type
|
|
500
|
+
let reqAccessPacket;
|
|
501
|
+
if (detectedEncryptionType === 2) {
|
|
502
|
+
log(`[Handshake] Using Type 2 REQUEST_ACCESS`);
|
|
503
|
+
reqAccessPacket = session.buildRequestAccessType2();
|
|
504
|
+
} else {
|
|
505
|
+
log(`[Handshake] Using Type 1 REQUEST_ACCESS`);
|
|
506
|
+
reqAccessPacket = session.buildRequestAccessType1();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
log(`[Handshake] Pending receive key stored: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
510
|
+
sendPacket(reqAccessPacket);
|
|
511
|
+
|
|
512
|
+
// Enable receive encryption immediately - panel will respond with encrypted COMMAND_RESPONSE
|
|
513
|
+
session.receiveAesActive = true;
|
|
514
|
+
session.receiveAesKey = session.pendingReceiveKey;
|
|
515
|
+
log(`[Handshake] Enabled receive encryption with RECV key: ${session.receiveAesKey?.toString('hex')}`);
|
|
516
|
+
|
|
517
|
+
handshakeState = 'SENT_REQUEST_ACCESS';
|
|
518
|
+
} else if (handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
519
|
+
// Panel ACKed our REQUEST_ACCESS - session is established!
|
|
520
|
+
handshakeState = 'ESTABLISHED';
|
|
521
|
+
log(`[Handshake] *** SESSION ESTABLISHED ***`);
|
|
522
|
+
log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
|
|
523
|
+
log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
524
|
+
|
|
525
|
+
// NOW enable encryption for post-handshake communication
|
|
526
|
+
if (session.derivedSendKey) {
|
|
527
|
+
session.enableSendAes(session.derivedSendKey);
|
|
528
|
+
log(`[Handshake] SEND AES now enabled: ${session.derivedSendKey.toString('hex')}`);
|
|
529
|
+
}
|
|
530
|
+
if (session.pendingReceiveKey) {
|
|
531
|
+
session.enableReceiveAes(session.pendingReceiveKey);
|
|
532
|
+
log(`[Handshake] RECV AES now enabled: ${session.pendingReceiveKey.toString('hex')}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
console.log(`\n[Ready] Session established!`);
|
|
536
|
+
console.log(` SEND key: ${session.derivedSendKey?.toString('hex')}`);
|
|
537
|
+
console.log(` RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
538
|
+
console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handleCommandResponse(parsed) {
|
|
543
|
+
const data = parsed.commandData;
|
|
544
|
+
// COMMAND_RESPONSE payload format: [echoed_byte, response_code]
|
|
545
|
+
// Note: parseHeader treats byte 4 as appSequence, which for COMMAND_RESPONSE is actually the echoed byte
|
|
546
|
+
const appSeqAsEcho = parsed.appSequence; // This is actually the echoed byte from our request
|
|
547
|
+
const echoedByte = data && data.length >= 1 ? data[0] : (appSeqAsEcho !== undefined ? appSeqAsEcho : 0xFF);
|
|
548
|
+
const responseCode = data && data.length >= 1 ? data[0] : 0xFF;
|
|
549
|
+
log(`[Handshake] Got COMMAND_RESPONSE (echoed app_seq: ${appSeqAsEcho}, response_code: ${responseCode}), sending ACK`);
|
|
550
|
+
|
|
551
|
+
const ack = session.buildSimpleAck();
|
|
552
|
+
sendPacket(ack);
|
|
553
|
+
|
|
554
|
+
if (handshakeState === 'SENT_OPEN_SESSION') {
|
|
555
|
+
// Just waiting for panel's REQUEST_ACCESS now
|
|
556
|
+
handshakeState = 'WAITING_REQUEST_ACCESS';
|
|
557
|
+
} else if (handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
558
|
+
// Session established!
|
|
559
|
+
handshakeState = 'ESTABLISHED';
|
|
560
|
+
log(`[Handshake] *** SESSION ESTABLISHED ***`);
|
|
561
|
+
log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
|
|
562
|
+
log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
563
|
+
|
|
564
|
+
// NOW enable send encryption for post-handshake commands
|
|
565
|
+
if (session.derivedSendKey) {
|
|
566
|
+
session.enableSendAes(session.derivedSendKey);
|
|
567
|
+
log(`[Handshake] Send AES ENABLED with key: ${session.derivedSendKey.toString('hex')}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Enable receive AES decryption in case panel encrypts responses
|
|
571
|
+
if (session.pendingReceiveKey) {
|
|
572
|
+
session.enableReceiveAes(session.pendingReceiveKey);
|
|
573
|
+
log(`[Handshake] Receive AES ENABLED with key: ${session.pendingReceiveKey.toString('hex')}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
console.log(`\n[Ready] Session established (Type ${detectedEncryptionType || 1} encryption)`);
|
|
577
|
+
console.log(` SEND key: ${session.derivedSendKey?.toString('hex')}`);
|
|
578
|
+
console.log(` RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
579
|
+
console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
|
|
580
|
+
|
|
581
|
+
// Wait for session to stabilize, then send test zone query
|
|
582
|
+
// setTimeout(() => {
|
|
583
|
+
// sendTestZoneQuery();
|
|
584
|
+
// }, 3000);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function handleCommandError(parsed) {
|
|
589
|
+
const errorData = parsed.commandData;
|
|
590
|
+
log(`[Handshake] Received COMMAND_ERROR response`);
|
|
591
|
+
if (errorData) {
|
|
592
|
+
log(`[Handshake] Error data: ${errorData.toString('hex')}`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Send ACK anyway
|
|
596
|
+
const ack = session.buildSimpleAck();
|
|
597
|
+
sendPacket(ack);
|
|
598
|
+
|
|
599
|
+
if (handshakeState === 'SENT_REQUEST_ACCESS') {
|
|
600
|
+
// Session established despite error (we have keys)
|
|
601
|
+
handshakeState = 'ESTABLISHED';
|
|
602
|
+
log(`[Handshake] *** SESSION ESTABLISHED (despite error response) ***`);
|
|
603
|
+
log(`[Handshake] Derived SEND key: ${session.derivedSendKey?.toString('hex')}`);
|
|
604
|
+
log(`[Handshake] Pending RECV key: ${session.pendingReceiveKey?.toString('hex')}`);
|
|
605
|
+
console.log(`\n[Ready] Session established! (frames remain plaintext per SDK analysis)`);
|
|
606
|
+
console.log(` Commands: z (zone status), p (partition), g (global), auth (authenticate)\n`);
|
|
607
|
+
|
|
608
|
+
// Wait for session to stabilize, then send test zone query
|
|
609
|
+
// setTimeout(() => {
|
|
610
|
+
// sendTestZoneQuery();
|
|
611
|
+
// }, 3000);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
server.on('message', (msg, rinfo) => {
|
|
616
|
+
log(`\n[UDP] RX from ${rinfo.address}:${rinfo.port} (${msg.length} bytes)`);
|
|
617
|
+
hexDump(msg);
|
|
618
|
+
|
|
619
|
+
// Track panel address
|
|
620
|
+
if (!panelAddress || panelAddress !== rinfo.address) {
|
|
621
|
+
panelAddress = rinfo.address;
|
|
622
|
+
panelPort = rinfo.port;
|
|
623
|
+
log(`[Session] Creating new session for ${panelAddress}`);
|
|
624
|
+
session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
|
|
625
|
+
handshakeState = 'WAITING';
|
|
626
|
+
testCommandSent = false;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
const parsed = session.parsePacket(msg);
|
|
631
|
+
|
|
632
|
+
log(`[Packet] Integration ID: ${parsed.integrationId}`);
|
|
633
|
+
log(`[Packet] Sender Seq: ${parsed.senderSequence}, Receiver Seq: ${parsed.receiverSequence}`);
|
|
634
|
+
|
|
635
|
+
const cmdName = CMD_NAMES[parsed.command] || `0x${parsed.command?.toString(16)}`;
|
|
636
|
+
log(`[Packet] Command: ${cmdName}`);
|
|
637
|
+
|
|
638
|
+
if (parsed.appSequence !== undefined) {
|
|
639
|
+
log(`[Packet] App Seq: ${parsed.appSequence}`);
|
|
640
|
+
}
|
|
641
|
+
if (parsed.commandData && parsed.commandData.length > 0) {
|
|
642
|
+
log(`[Packet] Data (${parsed.commandData.length} bytes): ${parsed.commandData.toString('hex').toUpperCase().match(/.{2}/g).join(' ')}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Handle by command
|
|
646
|
+
// Note: ACK packets have no command field (null), treat as SIMPLE_ACK
|
|
647
|
+
const cmd = parsed.command;
|
|
648
|
+
|
|
649
|
+
if (cmd === null || cmd === undefined || cmd === CMD.SIMPLE_ACK) {
|
|
650
|
+
handleSimpleAck(parsed);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
switch (cmd) {
|
|
655
|
+
case CMD.OPEN_SESSION:
|
|
656
|
+
handleOpenSession(parsed);
|
|
657
|
+
break;
|
|
658
|
+
case CMD.REQUEST_ACCESS:
|
|
659
|
+
handleRequestAccess(parsed);
|
|
660
|
+
break;
|
|
661
|
+
case CMD.COMMAND_RESPONSE:
|
|
662
|
+
handleCommandResponse(parsed);
|
|
663
|
+
break;
|
|
664
|
+
case CMD.COMMAND_ERROR:
|
|
665
|
+
handleCommandError(parsed);
|
|
666
|
+
break;
|
|
667
|
+
case CMD.POLL:
|
|
668
|
+
log(`[Session] Got POLL, sending ACK`);
|
|
669
|
+
sendPacket(session.buildSimpleAck());
|
|
670
|
+
break;
|
|
671
|
+
case CMD.ZONE_STATUS:
|
|
672
|
+
handleZoneStatusResponse(parsed);
|
|
673
|
+
break;
|
|
674
|
+
case CMD.PARTITION_STATUS:
|
|
675
|
+
handlePartitionStatusResponse(parsed);
|
|
676
|
+
break;
|
|
677
|
+
case CMD.GLOBAL_STATUS:
|
|
678
|
+
handleGlobalStatusResponse(parsed);
|
|
679
|
+
break;
|
|
680
|
+
case CMD.SYSTEM_CAPABILITIES:
|
|
681
|
+
handleSystemResponse('SYSTEM_CAPABILITIES', parsed);
|
|
682
|
+
break;
|
|
683
|
+
case CMD.SOFTWARE_VERSION:
|
|
684
|
+
handleSystemResponse('SOFTWARE_VERSION', parsed);
|
|
685
|
+
break;
|
|
686
|
+
case CMD.PANEL_STATUS:
|
|
687
|
+
handleSystemResponse('PANEL_STATUS', parsed);
|
|
688
|
+
break;
|
|
689
|
+
case CMD.BUFFER_SIZES:
|
|
690
|
+
handleSystemResponse('BUFFER_SIZES', parsed);
|
|
691
|
+
break;
|
|
692
|
+
// Notification events from panel
|
|
693
|
+
case CMD.NOTIFICATION_ARMING:
|
|
694
|
+
handlePartitionArmingNotification(parsed);
|
|
695
|
+
break;
|
|
696
|
+
case CMD.NOTIFICATION_PARTITION_READY:
|
|
697
|
+
handlePartitionReadyNotification(parsed);
|
|
698
|
+
break;
|
|
699
|
+
case CMD.NOTIFICATION_PARTITION_TROUBLE:
|
|
700
|
+
handlePartitionTroubleNotification(parsed);
|
|
701
|
+
break;
|
|
702
|
+
case CMD.ZONE_ALARM_STATUS:
|
|
703
|
+
handleZoneAlarmNotification(parsed);
|
|
704
|
+
break;
|
|
705
|
+
case CMD.LIFESTYLE_ZONE_STATUS:
|
|
706
|
+
handleLifestyleZoneNotification(parsed);
|
|
707
|
+
break;
|
|
708
|
+
case CMD.TIME_DATE_BROADCAST:
|
|
709
|
+
handleTimeDateNotification(parsed);
|
|
710
|
+
break;
|
|
711
|
+
case CMD.GENERAL_NOTIFICATION:
|
|
712
|
+
case CMD.DATA_UPDATE_NOTIFICATION:
|
|
713
|
+
log(`[Notification] Received ${CMD_NAMES[cmd] || cmd} notification`);
|
|
714
|
+
if (parsed.commandData) {
|
|
715
|
+
log(`[Notification] Data: ${parsed.commandData.toString('hex')}`);
|
|
716
|
+
}
|
|
717
|
+
sendPacket(session.buildSimpleAck());
|
|
718
|
+
break;
|
|
719
|
+
default:
|
|
720
|
+
if (handshakeState === 'ESTABLISHED') {
|
|
721
|
+
log(`[Session] Handling command ${CMD_NAMES[cmd] || '0x' + cmd.toString(16)} in established session`);
|
|
722
|
+
// Send ACK for unhandled commands
|
|
723
|
+
sendPacket(session.buildSimpleAck());
|
|
724
|
+
}
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
log(`[Error] ${err.message}`);
|
|
729
|
+
|
|
730
|
+
// If we get a parse error, the panel might be sending encrypted data from a stale session
|
|
731
|
+
// Reset our state and wait for panel to timeout and restart with plaintext OPEN_SESSION
|
|
732
|
+
if (handshakeState !== 'WAITING') {
|
|
733
|
+
log(`[Recovery] Parse error during handshake state '${handshakeState}', resetting to wait for panel restart`);
|
|
734
|
+
handshakeState = 'WAITING';
|
|
735
|
+
session = new ITv2Session(INTEGRATION_ID, ACCESS_CODE, log);
|
|
736
|
+
log(`[Recovery] Session reset. Waiting for panel to timeout and send plaintext OPEN_SESSION...`);
|
|
737
|
+
} else {
|
|
738
|
+
log(`[Recovery] Ignoring unparseable packet, waiting for plaintext OPEN_SESSION...`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
server.on('listening', () => {
|
|
744
|
+
const addr = server.address();
|
|
745
|
+
log(`[UDP] Server listening on ${addr.address}:${addr.port}`);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
server.on('error', (err) => {
|
|
749
|
+
log(`[UDP] Server error: ${err.message}`);
|
|
750
|
+
server.close();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Interactive command handling
|
|
754
|
+
const rl = readline.createInterface({
|
|
755
|
+
input: process.stdin,
|
|
756
|
+
output: process.stdout
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
rl.on('line', (line) => {
|
|
760
|
+
const [cmd, ...args] = line.trim().split(/\s+/);
|
|
761
|
+
|
|
762
|
+
if (!session || handshakeState !== 'ESTABLISHED') {
|
|
763
|
+
console.log('[Error] Session not established');
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
switch (cmd) {
|
|
768
|
+
case 'auth': {
|
|
769
|
+
const code = args[0] || MASTER_CODE;
|
|
770
|
+
const partition = parseInt(args[1] || '1', 10);
|
|
771
|
+
log(`[Command] Authenticating with code ${code} on partition ${partition}`);
|
|
772
|
+
const packet = session.buildAccessLevelEnter(code, partition, 0, 'bcd');
|
|
773
|
+
sendPacket(packet);
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ============ Arm/Disarm Commands ============
|
|
778
|
+
case 'arm': {
|
|
779
|
+
const partition = parseInt(args[0] || '1', 10);
|
|
780
|
+
const mode = parseInt(args[1] || '1', 10); // 0=stay, 1=away, 2=no-entry, 3=force
|
|
781
|
+
const code = args[2] || MASTER_CODE;
|
|
782
|
+
|
|
783
|
+
const modeNames = ['STAY', 'AWAY', 'NO_ENTRY', 'FORCE'];
|
|
784
|
+
log(`[Command] Arming partition ${partition} in ${modeNames[mode] || mode} mode`);
|
|
785
|
+
const packet = session.buildPartitionArm(partition, mode, code);
|
|
786
|
+
sendPacket(packet);
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
case 'disarm': {
|
|
790
|
+
const partition = parseInt(args[0] || '1', 10);
|
|
791
|
+
const code = args[1] || MASTER_CODE;
|
|
792
|
+
|
|
793
|
+
log(`[Command] Disarming partition ${partition}`);
|
|
794
|
+
const packet = session.buildPartitionDisarm(partition, code);
|
|
795
|
+
sendPacket(packet);
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case 'stay': {
|
|
799
|
+
// Shortcut for arm stay
|
|
800
|
+
const partition = parseInt(args[0] || '1', 10);
|
|
801
|
+
const code = args[1] || MASTER_CODE;
|
|
802
|
+
|
|
803
|
+
log(`[Command] Arming partition ${partition} in STAY mode`);
|
|
804
|
+
const packet = session.buildPartitionArm(partition, 0, code);
|
|
805
|
+
sendPacket(packet);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
case 'away': {
|
|
809
|
+
// Shortcut for arm away
|
|
810
|
+
const partition = parseInt(args[0] || '1', 10);
|
|
811
|
+
const code = args[1] || MASTER_CODE;
|
|
812
|
+
|
|
813
|
+
log(`[Command] Arming partition ${partition} in AWAY mode`);
|
|
814
|
+
const packet = session.buildPartitionArm(partition, 1, code);
|
|
815
|
+
sendPacket(packet);
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ============ Status Display Commands ============
|
|
820
|
+
case 'zones': {
|
|
821
|
+
// Show all zones with status from EventHandler
|
|
822
|
+
const zones = eventHandler.getAllZones();
|
|
823
|
+
console.log(`\n========================================`);
|
|
824
|
+
console.log(`ALL ZONES (${zones.length} with status)`);
|
|
825
|
+
console.log(`========================================`);
|
|
826
|
+
if (zones.length === 0) {
|
|
827
|
+
console.log(' No zone status received yet');
|
|
828
|
+
console.log(' (Open/close a zone to trigger notification)');
|
|
829
|
+
} else {
|
|
830
|
+
zones.forEach(z => {
|
|
831
|
+
const flags = [];
|
|
832
|
+
if (z.open) flags.push('OPEN');
|
|
833
|
+
if (z.bypassed) flags.push('BYPASSED');
|
|
834
|
+
if (z.alarm) flags.push('ALARM');
|
|
835
|
+
if (z.trouble) flags.push('TROUBLE');
|
|
836
|
+
if (z.tamper) flags.push('TAMPER');
|
|
837
|
+
console.log(` Zone ${z.zoneNumber}: ${flags.length > 0 ? flags.join(', ') : 'OK/CLOSED'}`);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
console.log(`========================================\n`);
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case 'partitions': {
|
|
844
|
+
// Show all partitions with status from EventHandler
|
|
845
|
+
const partitions = eventHandler.getAllPartitions();
|
|
846
|
+
console.log(`\n========================================`);
|
|
847
|
+
console.log(`ALL PARTITIONS (${partitions.length} with status)`);
|
|
848
|
+
console.log(`========================================`);
|
|
849
|
+
if (partitions.length === 0) {
|
|
850
|
+
console.log(' No partition status received yet');
|
|
851
|
+
console.log(' (Arm/disarm to trigger notification)');
|
|
852
|
+
} else {
|
|
853
|
+
partitions.forEach(p => {
|
|
854
|
+
console.log(` Partition ${p.partitionNumber}: ${p.armedStateName || 'UNKNOWN'} ${p.ready ? '[READY]' : '[NOT READY]'}`);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
console.log(`========================================\n`);
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case 'status': {
|
|
861
|
+
// Show complete system status
|
|
862
|
+
eventHandler.printStatus();
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ============ Query Commands ============
|
|
867
|
+
case 'p': {
|
|
868
|
+
const partition = parseInt(args[0] || '1', 10);
|
|
869
|
+
log(`[Command] Querying partition ${partition} status`);
|
|
870
|
+
const packet = session.buildPartitionStatusRequest(partition);
|
|
871
|
+
sendPacket(packet);
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
case 'z': {
|
|
875
|
+
const zone = parseInt(args[0] || '0', 10);
|
|
876
|
+
log(`[Command] Querying zone ${zone} status`);
|
|
877
|
+
const packet = session.buildZoneStatusRequest(zone);
|
|
878
|
+
sendPacket(packet);
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case 'g': {
|
|
882
|
+
log(`[Command] Querying global status`);
|
|
883
|
+
const packet = session.buildGlobalStatusRequest();
|
|
884
|
+
sendPacket(packet);
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
case 't': {
|
|
888
|
+
log(`[Command] Querying trouble status`);
|
|
889
|
+
const packet = session.buildTroubleStatusRequest();
|
|
890
|
+
sendPacket(packet);
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
case 'b': {
|
|
894
|
+
const zone = parseInt(args[0] || '0', 10);
|
|
895
|
+
log(`[Command] Querying zone ${zone} bypass status`);
|
|
896
|
+
const packet = session.buildZoneBypassStatusRequest(zone);
|
|
897
|
+
sendPacket(packet);
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
case 'cap': {
|
|
901
|
+
log(`[Command] Requesting system capabilities`);
|
|
902
|
+
const packet = session.buildCommand(CMD.SYSTEM_CAPABILITIES);
|
|
903
|
+
sendPacket(packet);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
case 'poll': {
|
|
907
|
+
log(`[Command] Sending POLL/keepalive`);
|
|
908
|
+
const packet = session.buildPoll();
|
|
909
|
+
sendPacket(packet);
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
case 'exit':
|
|
913
|
+
case 'quit': {
|
|
914
|
+
console.log('[Shutdown] Closing session...');
|
|
915
|
+
if (session && handshakeState === 'ESTABLISHED') {
|
|
916
|
+
try {
|
|
917
|
+
const endSessionPacket = session.buildEndSession();
|
|
918
|
+
sendPacket(endSessionPacket);
|
|
919
|
+
console.log('[Shutdown] END_SESSION sent to panel');
|
|
920
|
+
} catch (err) {
|
|
921
|
+
console.log(`[Shutdown] Error: ${err.message}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
setTimeout(() => {
|
|
925
|
+
console.log('[Shutdown] Goodbye!');
|
|
926
|
+
process.exit(0);
|
|
927
|
+
}, 100);
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
case 'help':
|
|
931
|
+
console.log(`
|
|
932
|
+
Interactive Commands:
|
|
933
|
+
|
|
934
|
+
ARM/DISARM:
|
|
935
|
+
arm <part> <mode> [code] - Arm partition (mode: 0=stay, 1=away, 2=no-entry, 3=force)
|
|
936
|
+
disarm <part> [code] - Disarm partition
|
|
937
|
+
stay [part] [code] - Shortcut: Arm partition in STAY mode
|
|
938
|
+
away [part] [code] - Shortcut: Arm partition in AWAY mode
|
|
939
|
+
|
|
940
|
+
STATUS DISPLAY:
|
|
941
|
+
zones - Show all zones with current status
|
|
942
|
+
partitions - Show all partitions with current status
|
|
943
|
+
status - Show complete system status summary
|
|
944
|
+
|
|
945
|
+
QUERY PANEL:
|
|
946
|
+
auth [code] - Authenticate with access code (default=${MASTER_CODE})
|
|
947
|
+
p [n] - Query partition n status (default=1)
|
|
948
|
+
z [n] - Query zone n status (default=0=all)
|
|
949
|
+
g - Query global status
|
|
950
|
+
t - Query trouble status
|
|
951
|
+
b [n] - Query zone bypass status
|
|
952
|
+
cap - Request system capabilities
|
|
953
|
+
poll - Send poll/keepalive
|
|
954
|
+
|
|
955
|
+
OTHER:
|
|
956
|
+
help - Show this help
|
|
957
|
+
|
|
958
|
+
Examples:
|
|
959
|
+
arm 1 0 5555 - Arm partition 1 in STAY mode with code 5555
|
|
960
|
+
arm 1 1 - Arm partition 1 in AWAY mode with default code
|
|
961
|
+
disarm 1 - Disarm partition 1 with default code
|
|
962
|
+
stay - Arm partition 1 in STAY mode
|
|
963
|
+
away - Arm partition 1 in AWAY mode
|
|
964
|
+
zones - Show current zone status
|
|
965
|
+
status - Show system summary
|
|
966
|
+
`);
|
|
967
|
+
break;
|
|
968
|
+
default:
|
|
969
|
+
console.log(`[Error] Unknown command: ${cmd}. Type 'help' for available commands.`);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// Print banner
|
|
974
|
+
console.log(`
|
|
975
|
+
============================================================
|
|
976
|
+
DSC TL280R ITV2 Protocol Server (Enhanced)
|
|
977
|
+
============================================================
|
|
978
|
+
|
|
979
|
+
Configuration:
|
|
980
|
+
Integration ID: ${INTEGRATION_ID}
|
|
981
|
+
Access Code: ${ACCESS_CODE}
|
|
982
|
+
Master Code: ${MASTER_CODE}
|
|
983
|
+
UDP Port: ${UDP_PORT}
|
|
984
|
+
|
|
985
|
+
Set environment variables to override:
|
|
986
|
+
INTEGRATION_ID=${INTEGRATION_ID} ACCESS_CODE=12345678 MASTER_CODE=${MASTER_CODE} npm run server
|
|
987
|
+
|
|
988
|
+
Interactive Commands (type when session established):
|
|
989
|
+
|
|
990
|
+
ARM/DISARM:
|
|
991
|
+
arm <part> <mode> [code] - Arm partition (mode: 0=stay, 1=away)
|
|
992
|
+
disarm <part> [code] - Disarm partition
|
|
993
|
+
stay [part] [code] - Arm in STAY mode
|
|
994
|
+
away [part] [code] - Arm in AWAY mode
|
|
995
|
+
|
|
996
|
+
STATUS:
|
|
997
|
+
zones - Show all zones with status
|
|
998
|
+
partitions - Show all partitions with status
|
|
999
|
+
status - Show system status summary
|
|
1000
|
+
|
|
1001
|
+
QUERY:
|
|
1002
|
+
z [n], p [n], g, t, b [n] - Query zone/partition/global/trouble/bypass
|
|
1003
|
+
auth [code], cap, poll - Authenticate/capabilities/keepalive
|
|
1004
|
+
|
|
1005
|
+
OTHER:
|
|
1006
|
+
exit, quit - Close session and exit gracefully
|
|
1007
|
+
|
|
1008
|
+
Type 'help' for full command list.
|
|
1009
|
+
|
|
1010
|
+
Waiting for TL280R to connect...
|
|
1011
|
+
`);
|
|
1012
|
+
|
|
1013
|
+
server.bind(UDP_PORT, '0.0.0.0');
|
|
1014
|
+
|
|
1015
|
+
// Graceful shutdown handler
|
|
1016
|
+
process.on('SIGINT', () => {
|
|
1017
|
+
console.log('\n\n[Shutdown] Closing session gracefully...');
|
|
1018
|
+
|
|
1019
|
+
if (session && handshakeState === 'ESTABLISHED') {
|
|
1020
|
+
try {
|
|
1021
|
+
const endSessionPacket = session.buildEndSession();
|
|
1022
|
+
sendPacket(endSessionPacket);
|
|
1023
|
+
console.log('[Shutdown] END_SESSION sent to panel');
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
console.log(`[Shutdown] Error sending END_SESSION: ${err.message}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
setTimeout(() => {
|
|
1030
|
+
console.log('[Shutdown] Goodbye!');
|
|
1031
|
+
process.exit(0);
|
|
1032
|
+
}, 100);
|
|
1033
|
+
});
|