dsc-itv2-client 2.0.1 → 2.0.3
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 +92 -10
- package/src/examples/interactive-cli.js +40 -10
- package/src/itv2-session.js +47 -0
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.3",
|
|
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
|
@@ -218,49 +218,108 @@ export class ITV2Client extends EventEmitter {
|
|
|
218
218
|
this.emit('session:closed');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Send an arm/disarm command and wait for confirmation.
|
|
223
|
+
* Resolves when partition:arming or partition:exitDelay event arrives.
|
|
224
|
+
* Rejects on timeout (panel silently refused, e.g. open zones).
|
|
225
|
+
* @returns {Promise<object>} The arming/exitDelay event data
|
|
226
|
+
*/
|
|
227
|
+
async _sendArmCommand(packet, partition, expectedMode, timeout = 5000) {
|
|
228
|
+
if (!this._checkEstablished()) {
|
|
229
|
+
throw new Error('Session not established');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Wait for arming/exitDelay confirmation
|
|
233
|
+
try {
|
|
234
|
+
return await new Promise((resolve, reject) => {
|
|
235
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, timeout);
|
|
236
|
+
|
|
237
|
+
const onArming = (data) => {
|
|
238
|
+
if (data.partition === partition) { cleanup(); resolve(data); }
|
|
239
|
+
};
|
|
240
|
+
const onDelay = (data) => {
|
|
241
|
+
if (data.partition === partition) { cleanup(); resolve(data); }
|
|
242
|
+
};
|
|
243
|
+
const cleanup = () => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
this.removeListener('partition:arming', onArming);
|
|
246
|
+
this.removeListener('partition:exitDelay', onDelay);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
this.on('partition:arming', onArming);
|
|
250
|
+
this.on('partition:exitDelay', onDelay);
|
|
251
|
+
this._sendPacket(packet);
|
|
252
|
+
});
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if (e.message !== 'timeout') throw e;
|
|
255
|
+
|
|
256
|
+
// No confirmation — query partition status to check current state
|
|
257
|
+
try {
|
|
258
|
+
const status = await this.queryPartitionStatus(partition);
|
|
259
|
+
if (status) {
|
|
260
|
+
const state = status.awayArmed ? 'AWAY' :
|
|
261
|
+
status.stayArmed ? 'STAY' :
|
|
262
|
+
status.nightArmed ? 'NIGHT' :
|
|
263
|
+
status.armed ? 'ARMED' : 'DISARMED';
|
|
264
|
+
|
|
265
|
+
// If already in the requested state, treat as success
|
|
266
|
+
if (state === expectedMode) {
|
|
267
|
+
return { partition, modeName: state, fromQuery: true };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (_) {}
|
|
271
|
+
|
|
272
|
+
throw new Error('Arm/disarm not confirmed - panel may have open zones or troubles');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
221
276
|
/**
|
|
222
277
|
* Arm partition in stay mode (mode 1)
|
|
278
|
+
* @returns {Promise} Resolves with arming confirmation, rejects if panel refuses
|
|
223
279
|
*/
|
|
224
280
|
armStay(partition, code) {
|
|
225
|
-
if (!this._checkEstablished()) return;
|
|
226
281
|
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.STAY, code || this.masterCode);
|
|
227
|
-
this.
|
|
282
|
+
return this._sendArmCommand(packet, partition, 'STAY');
|
|
228
283
|
}
|
|
229
284
|
|
|
230
285
|
/**
|
|
231
286
|
* Arm partition in away mode (mode 2)
|
|
287
|
+
* @returns {Promise} Resolves with arming confirmation, rejects if panel refuses
|
|
232
288
|
*/
|
|
233
289
|
armAway(partition, code) {
|
|
234
|
-
if (!this._checkEstablished()) return;
|
|
235
290
|
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.AWAY, code || this.masterCode);
|
|
236
|
-
this.
|
|
291
|
+
return this._sendArmCommand(packet, partition, 'AWAY');
|
|
237
292
|
}
|
|
238
293
|
|
|
239
294
|
/**
|
|
240
295
|
* Arm partition in night mode (mode 4)
|
|
296
|
+
* @returns {Promise} Resolves with arming confirmation, rejects if panel refuses
|
|
241
297
|
*/
|
|
242
298
|
armNight(partition, code) {
|
|
243
|
-
if (!this._checkEstablished()) return;
|
|
244
299
|
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NIGHT, code || this.masterCode);
|
|
245
|
-
this.
|
|
300
|
+
return this._sendArmCommand(packet, partition, 'NIGHT');
|
|
246
301
|
}
|
|
247
302
|
|
|
248
303
|
/**
|
|
249
304
|
* Arm partition with no entry delay (mode 3)
|
|
305
|
+
* @returns {Promise} Resolves with arming confirmation, rejects if panel refuses
|
|
250
306
|
*/
|
|
251
307
|
armNoEntryDelay(partition, code) {
|
|
252
|
-
if (!this._checkEstablished()) return;
|
|
253
308
|
const packet = this.session.buildPartitionArm(partition, ITv2Session.ARM_MODE.NO_ENTRY_DELAY, code || this.masterCode);
|
|
254
|
-
this.
|
|
309
|
+
return this._sendArmCommand(packet, partition, 'ARMED');
|
|
255
310
|
}
|
|
256
311
|
|
|
312
|
+
// TODO: Zone bypass commands (0x074A) require a programming mode flow
|
|
313
|
+
// (ITV2_ProgModeInfo) that hasn't been fully reverse-engineered.
|
|
314
|
+
// Bypass STATUS monitoring works via 0x0820 notifications and zone:bypass events.
|
|
315
|
+
|
|
257
316
|
/**
|
|
258
317
|
* Disarm partition
|
|
318
|
+
* @returns {Promise} Resolves with disarm confirmation, rejects if panel refuses
|
|
259
319
|
*/
|
|
260
320
|
disarm(partition, code) {
|
|
261
|
-
if (!this._checkEstablished()) return;
|
|
262
321
|
const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
|
|
263
|
-
this.
|
|
322
|
+
return this._sendArmCommand(packet, partition, 'DISARMED');
|
|
264
323
|
}
|
|
265
324
|
|
|
266
325
|
// ==================== Status Query Methods ====================
|
|
@@ -578,6 +637,9 @@ export class ITV2Client extends EventEmitter {
|
|
|
578
637
|
case CMD.NOTIFICATION_PARTITION_TROUBLE:
|
|
579
638
|
this._handlePartitionTroubleNotification(parsed);
|
|
580
639
|
break;
|
|
640
|
+
case CMD.SINGLE_ZONE_BYPASS_STATUS:
|
|
641
|
+
this._handleZoneBypassNotification(parsed);
|
|
642
|
+
break;
|
|
581
643
|
case CMD.MULTIPLE_MESSAGE:
|
|
582
644
|
this._handleMultipleMessagePacket(parsed);
|
|
583
645
|
break;
|
|
@@ -1254,6 +1316,26 @@ export class ITV2Client extends EventEmitter {
|
|
|
1254
1316
|
|
|
1255
1317
|
// ==================== Notification Handlers ====================
|
|
1256
1318
|
|
|
1319
|
+
_handleZoneBypassNotification(parsed) {
|
|
1320
|
+
// 0x0820 SingleZoneBypassStatus (from neohub):
|
|
1321
|
+
// [CompactInt:ZoneNumber][BypassState:1B] (0=not bypassed, 1=bypassed)
|
|
1322
|
+
const fullPayload = this._reconstructPayload(parsed);
|
|
1323
|
+
if (fullPayload && fullPayload.length >= 3) {
|
|
1324
|
+
try {
|
|
1325
|
+
let offset = 0;
|
|
1326
|
+
const zone = ITv2Session.decodeVarBytes(fullPayload, offset);
|
|
1327
|
+
offset += zone.bytesRead;
|
|
1328
|
+
const bypassed = fullPayload[offset] === 1;
|
|
1329
|
+
|
|
1330
|
+
this._logMinimal(`[Zone ${zone.value}] ${bypassed ? 'BYPASSED' : 'UNBYPASSED'}`);
|
|
1331
|
+
this.emit('zone:bypass', { zone: zone.value, bypassed });
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
this._log(`[Bypass Status] Parse error: ${e.message}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
this._ack();
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1257
1339
|
_handleExitDelayNotification(parsed) {
|
|
1258
1340
|
// 0x0230 NotificationExitDelay (from neohub):
|
|
1259
1341
|
// [CompactInt:Partition][DelayFlags:1B][CompactInt:DurationInSeconds]
|
|
@@ -76,6 +76,23 @@ client.on('partition:ready', ({ partition, isReady }) => {
|
|
|
76
76
|
prompt();
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
client.on('zone:bypass', ({ zone, bypassed }) => {
|
|
80
|
+
console.log(`\n Zone ${zone}: ${bypassed ? 'BYPASSED' : 'UNBYPASSED'}`);
|
|
81
|
+
prompt();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
client.on('partition:exitDelay', ({ partition, duration, active }) => {
|
|
85
|
+
if (active) console.log(`\n Partition ${partition}: exit delay ${duration}s`);
|
|
86
|
+
prompt();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
client.on('trouble:detail', (troubles) => {
|
|
90
|
+
for (const t of troubles) {
|
|
91
|
+
console.log(`\n Trouble: ${t.deviceTypeName} #${t.deviceNumber}: ${t.troubleTypeName} (${t.troubleStateName})`);
|
|
92
|
+
}
|
|
93
|
+
prompt();
|
|
94
|
+
});
|
|
95
|
+
|
|
79
96
|
// command:error events are protocol-level noise (intermediate acks), not actionable
|
|
80
97
|
|
|
81
98
|
client.on('error', (err) => {
|
|
@@ -145,13 +162,14 @@ rl.on('line', async (line) => {
|
|
|
145
162
|
|
|
146
163
|
case 'trouble':
|
|
147
164
|
case 't': {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
165
|
+
// Trouble detail comes from 0x0823 notifications (not queryable)
|
|
166
|
+
// Check partition status for trouble flag
|
|
167
|
+
const ps = await client.queryPartitionStatus(1);
|
|
168
|
+
if (ps && ps.trouble) {
|
|
169
|
+
console.log('Partition 1 has active troubles');
|
|
170
|
+
console.log('(Detailed trouble info arrives via 0x0823 notifications - see trouble:detail event)');
|
|
151
171
|
} else {
|
|
152
|
-
|
|
153
|
-
console.log(` Device ${t.deviceType}: troubles [${t.troubles.join(', ')}]`);
|
|
154
|
-
}
|
|
172
|
+
console.log('No active troubles on partition 1');
|
|
155
173
|
}
|
|
156
174
|
break;
|
|
157
175
|
}
|
|
@@ -179,7 +197,10 @@ rl.on('line', async (line) => {
|
|
|
179
197
|
const part = parseInt(args[0] || '1');
|
|
180
198
|
const code = args[1] || config.masterCode;
|
|
181
199
|
console.log(`Arming partition ${part} STAY...`);
|
|
182
|
-
|
|
200
|
+
try {
|
|
201
|
+
const r = await client.armStay(part, code);
|
|
202
|
+
console.log(r.duration ? `Exit delay: ${r.duration}s` : `Partition ${part}: ${r.modeName}`);
|
|
203
|
+
} catch (e) { console.log(e.message); }
|
|
183
204
|
break;
|
|
184
205
|
}
|
|
185
206
|
|
|
@@ -187,7 +208,10 @@ rl.on('line', async (line) => {
|
|
|
187
208
|
const part = parseInt(args[0] || '1');
|
|
188
209
|
const code = args[1] || config.masterCode;
|
|
189
210
|
console.log(`Arming partition ${part} AWAY...`);
|
|
190
|
-
|
|
211
|
+
try {
|
|
212
|
+
const r = await client.armAway(part, code);
|
|
213
|
+
console.log(r.duration ? `Exit delay: ${r.duration}s` : `Partition ${part}: ${r.modeName}`);
|
|
214
|
+
} catch (e) { console.log(e.message); }
|
|
191
215
|
break;
|
|
192
216
|
}
|
|
193
217
|
|
|
@@ -195,7 +219,10 @@ rl.on('line', async (line) => {
|
|
|
195
219
|
const part = parseInt(args[0] || '1');
|
|
196
220
|
const code = args[1] || config.masterCode;
|
|
197
221
|
console.log(`Arming partition ${part} NIGHT...`);
|
|
198
|
-
|
|
222
|
+
try {
|
|
223
|
+
const r = await client.armNight(part, code);
|
|
224
|
+
console.log(r.duration ? `Exit delay: ${r.duration}s` : `Partition ${part}: ${r.modeName}`);
|
|
225
|
+
} catch (e) { console.log(e.message); }
|
|
199
226
|
break;
|
|
200
227
|
}
|
|
201
228
|
|
|
@@ -203,7 +230,10 @@ rl.on('line', async (line) => {
|
|
|
203
230
|
const part = parseInt(args[0] || '1');
|
|
204
231
|
const code = args[1] || config.masterCode;
|
|
205
232
|
console.log(`Disarming partition ${part}...`);
|
|
206
|
-
|
|
233
|
+
try {
|
|
234
|
+
const r = await client.disarm(part, code);
|
|
235
|
+
console.log(`Partition ${part}: ${r.modeName}`);
|
|
236
|
+
} catch (e) { console.log(e.message); }
|
|
207
237
|
break;
|
|
208
238
|
}
|
|
209
239
|
|
package/src/itv2-session.js
CHANGED
|
@@ -97,6 +97,8 @@ export const CMD = {
|
|
|
97
97
|
BUS_STATUS: 0x0816,
|
|
98
98
|
TROUBLE_DETAIL: 0x0823,
|
|
99
99
|
DOOR_CHIME_STATUS: 0x0819,
|
|
100
|
+
SINGLE_ZONE_BYPASS_STATUS: 0x0820,
|
|
101
|
+
SINGLE_ZONE_BYPASS_WRITE: 0x074A,
|
|
100
102
|
MULTIPLE_MESSAGE: 0x0623,
|
|
101
103
|
// Module Control commands
|
|
102
104
|
PARTITION_ARM: 0x0900,
|
|
@@ -1191,6 +1193,51 @@ export class ITv2Session {
|
|
|
1191
1193
|
return this.buildCommand(CMD.PARTITION_DISARM, payload);
|
|
1192
1194
|
}
|
|
1193
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Build SINGLE_ZONE_BYPASS_WRITE wrapped in ACCESS_CODE_WRAPPER (0x0703)
|
|
1198
|
+
* Configuration writes require access code authentication.
|
|
1199
|
+
* Wire format for 0x0703: [AccessCode ByteArray][SubCommand 2B BE][Data ByteArray]
|
|
1200
|
+
* OnHasAppSeqNum returns 0 — no CommandSequence in the payload.
|
|
1201
|
+
* @param {number} partition - Partition number (1-8)
|
|
1202
|
+
* @param {number} zone - Zone number
|
|
1203
|
+
* @param {boolean} bypass - true to bypass, false to unbypass
|
|
1204
|
+
* @param {string} accessCode - Master/user code (e.g., "1234")
|
|
1205
|
+
*/
|
|
1206
|
+
buildZoneBypass(partition, zone, bypass, accessCode) {
|
|
1207
|
+
// Inner 0x074A payload: [Partition VarBytes][ZoneNumber VarBytes][BypassState 1B]
|
|
1208
|
+
const innerPayload = Buffer.concat([
|
|
1209
|
+
this.encodeVarBytes(partition),
|
|
1210
|
+
this.encodeVarBytes(zone),
|
|
1211
|
+
Buffer.from([bypass ? 0x01 : 0x00]),
|
|
1212
|
+
]);
|
|
1213
|
+
|
|
1214
|
+
// Access code in BCD format
|
|
1215
|
+
const bcdCode = this._encodeBcd(accessCode);
|
|
1216
|
+
|
|
1217
|
+
// 0x0703 payload (no AppSeqNum): [AccessCode ByteArray][SubCommand 2B BE][Data ByteArray]
|
|
1218
|
+
const subCmd = Buffer.alloc(2);
|
|
1219
|
+
subCmd.writeUInt16BE(CMD.SINGLE_ZONE_BYPASS_WRITE);
|
|
1220
|
+
|
|
1221
|
+
const payload = Buffer.concat([
|
|
1222
|
+
this.encodeByteArray(bcdCode), // AccessCode as ByteArray (VarBytes length + data)
|
|
1223
|
+
subCmd, // SubCommand = 0x074A
|
|
1224
|
+
this.encodeByteArray(innerPayload), // Data as ByteArray
|
|
1225
|
+
]);
|
|
1226
|
+
|
|
1227
|
+
this.log(`[Session] ZONE_BYPASS via 0x0703: partition=${partition}, zone=${zone}, bypass=${bypass}`);
|
|
1228
|
+
|
|
1229
|
+
// 0x0703 has no AppSeqNum — send without appSequence
|
|
1230
|
+
this.localSequence = (this.localSequence + 1) & 0xFF;
|
|
1231
|
+
const message = buildHeader(
|
|
1232
|
+
this.localSequence,
|
|
1233
|
+
this.remoteSequence,
|
|
1234
|
+
CMD.CONFIG_ACCESS_CODE_WRAPPER,
|
|
1235
|
+
null, // No appSequence for 0x0703
|
|
1236
|
+
payload
|
|
1237
|
+
);
|
|
1238
|
+
return this.buildPacket(message);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1194
1241
|
/**
|
|
1195
1242
|
* Build command output activation (PGM trigger)
|
|
1196
1243
|
* @param {number} partition - Partition number
|