dsc-itv2-client 2.0.1 → 2.0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsc-itv2-client",
3
3
  "author": "fajitacat",
4
- "version": "2.0.1",
4
+ "version": "2.0.4",
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,221 @@ 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._sendPacket(packet);
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._sendPacket(packet);
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._sendPacket(packet);
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._sendPacket(packet);
309
+ return this._sendArmCommand(packet, partition, 'ARMED');
310
+ }
311
+
312
+ /**
313
+ * Bypass a zone (exclude from monitoring).
314
+ * Uses config mode: 0x0704 → 0x074A → 0x0701.
315
+ * @param {number} zone - Zone number to bypass
316
+ * @param {number} [partition=1] - Partition number
317
+ * @param {string} [code] - User/master code (defaults to masterCode)
318
+ * @returns {Promise<object>} Resolves with bypass confirmation
319
+ */
320
+ async bypassZone(zone, partition = 1, code) {
321
+ return this._sendBypassCommand(zone, partition, true, code);
322
+ }
323
+
324
+ /**
325
+ * Unbypass a zone (restore monitoring).
326
+ * @param {number} zone - Zone number to unbypass
327
+ * @param {number} [partition=1] - Partition number
328
+ * @param {string} [code] - User/master code (defaults to masterCode)
329
+ * @returns {Promise<object>} Resolves with unbypass confirmation
330
+ */
331
+ async unbypassZone(zone, partition = 1, code) {
332
+ return this._sendBypassCommand(zone, partition, false, code);
333
+ }
334
+
335
+ /**
336
+ * Send a zone bypass/unbypass command.
337
+ * Flow: 0x0704 (enter config mode) → wait for 0x0702 → 0x074A (bypass) → 0x0701 (exit)
338
+ * @private
339
+ */
340
+ async _sendBypassCommand(zone, partition, bypass, code, timeout = 10000) {
341
+ if (!this._checkEstablished()) {
342
+ throw new Error('Session not established');
343
+ }
344
+
345
+ const accessCode = code || this.masterCode;
346
+ const action = bypass ? 'BYPASS' : 'UNBYPASS';
347
+
348
+ // Step 1: Enter config mode (type=3 = zone bypass, mode=1 = user code)
349
+ const enterPacket = this.session.buildEnterConfigMode(partition, 3, accessCode, 1);
350
+ this._sendPacket(enterPacket);
351
+
352
+ // Step 2: Wait for 0x0702 config status confirmation (COMMAND_RESPONSE is intermediate)
353
+ await this._waitForConfigReady(5000);
354
+
355
+ // Step 3: Send zone bypass write
356
+ const bypassPacket = this.session.buildZoneBypass(partition, zone, bypass);
357
+
358
+ try {
359
+ const result = await new Promise((resolve, reject) => {
360
+ const timer = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, timeout);
361
+
362
+ const onBypass = (data) => {
363
+ if (data.zone === zone) { cleanup(); resolve(data); }
364
+ };
365
+ const cleanup = () => {
366
+ clearTimeout(timer);
367
+ this.removeListener('zone:bypass', onBypass);
368
+ };
369
+
370
+ this.on('zone:bypass', onBypass);
371
+ this._sendPacket(bypassPacket);
372
+ });
373
+
374
+ // Step 4: Exit config mode
375
+ const exitPacket = this.session.buildExitConfigMode(partition);
376
+ this._sendPacket(exitPacket);
377
+
378
+ return result;
379
+ } catch (e) {
380
+ // Always try to exit config mode
381
+ try {
382
+ const exitPacket = this.session.buildExitConfigMode(partition);
383
+ this._sendPacket(exitPacket);
384
+ } catch (_) {}
385
+
386
+ if (e.message !== 'timeout') throw e;
387
+
388
+ // No 0x0820 confirmation — query zone status to check
389
+ try {
390
+ const zones = await this.queryZoneStatus();
391
+ const zoneStatus = zones.find(z => z.zoneNumber === zone);
392
+ if (zoneStatus && zoneStatus.bypassed === bypass) {
393
+ return { zone, bypassed: bypass, fromQuery: true };
394
+ }
395
+ } catch (_) {}
396
+
397
+ throw new Error(`Zone ${zone} ${action} not confirmed - check access code and zone number`);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Wait for 0x0702 (config status) notification confirming config mode is ready.
403
+ * COMMAND_RESPONSE to 0x0704 is an intermediate ack — the real confirmation is 0x0702.
404
+ * @private
405
+ */
406
+ _waitForConfigReady(timeout = 10000) {
407
+ return new Promise((resolve) => {
408
+ const timer = setTimeout(() => { cleanup(); resolve(); }, timeout);
409
+ const origRoute = this._routePacket.bind(this);
410
+
411
+ const interceptor = (parsed, skipAck) => {
412
+ if (parsed.command === 0x0702) {
413
+ cleanup();
414
+ this._logMinimal('[Config] Config mode ready (0x0702)');
415
+ resolve();
416
+ }
417
+ origRoute(parsed, skipAck);
418
+ };
419
+
420
+ const cleanup = () => {
421
+ clearTimeout(timer);
422
+ this._routePacket = origRoute;
423
+ };
424
+
425
+ this._routePacket = interceptor;
426
+ });
255
427
  }
256
428
 
257
429
  /**
258
430
  * Disarm partition
431
+ * @returns {Promise} Resolves with disarm confirmation, rejects if panel refuses
259
432
  */
260
433
  disarm(partition, code) {
261
- if (!this._checkEstablished()) return;
262
434
  const packet = this.session.buildPartitionDisarm(partition, code || this.masterCode);
263
- this._sendPacket(packet);
435
+ return this._sendArmCommand(packet, partition, 'DISARMED');
264
436
  }
265
437
 
266
438
  // ==================== Status Query Methods ====================
@@ -578,6 +750,9 @@ export class ITV2Client extends EventEmitter {
578
750
  case CMD.NOTIFICATION_PARTITION_TROUBLE:
579
751
  this._handlePartitionTroubleNotification(parsed);
580
752
  break;
753
+ case CMD.SINGLE_ZONE_BYPASS_STATUS:
754
+ this._handleZoneBypassNotification(parsed);
755
+ break;
581
756
  case CMD.MULTIPLE_MESSAGE:
582
757
  this._handleMultipleMessagePacket(parsed);
583
758
  break;
@@ -1254,6 +1429,32 @@ export class ITV2Client extends EventEmitter {
1254
1429
 
1255
1430
  // ==================== Notification Handlers ====================
1256
1431
 
1432
+ _handleZoneBypassNotification(parsed) {
1433
+ // 0x0820 SingleZoneBypassStatus (from neohub):
1434
+ // [CompactInt:ZoneNumber][BypassState:1B] (0=not bypassed, 1=bypassed)
1435
+ const fullPayload = this._reconstructPayload(parsed);
1436
+ if (fullPayload && fullPayload.length >= 3) {
1437
+ try {
1438
+ let offset = 0;
1439
+ const zone = ITv2Session.decodeVarBytes(fullPayload, offset);
1440
+ offset += zone.bytesRead;
1441
+ const bypassed = fullPayload[offset] === 1;
1442
+
1443
+ this._logMinimal(`[Zone ${zone.value}] ${bypassed ? 'BYPASSED' : 'UNBYPASSED'}`);
1444
+
1445
+ // Update cached zone state
1446
+ if (this.eventHandler) {
1447
+ this.eventHandler.handleZoneBypass(zone.value, bypassed);
1448
+ }
1449
+
1450
+ this.emit('zone:bypass', { zone: zone.value, bypassed });
1451
+ } catch (e) {
1452
+ this._log(`[Bypass Status] Parse error: ${e.message}`);
1453
+ }
1454
+ }
1455
+ this._ack();
1456
+ }
1457
+
1257
1458
  _handleExitDelayNotification(parsed) {
1258
1459
  // 0x0230 NotificationExitDelay (from neohub):
1259
1460
  // [CompactInt:Partition][DelayFlags:1B][CompactInt:DurationInSeconds]
package/src/constants.js CHANGED
@@ -47,6 +47,9 @@ export const Commands = {
47
47
  PARTITION_BYPASS_STATUS: 0x0240,
48
48
 
49
49
  // Configuration (0x07xx)
50
+ CONFIG_EXIT: 0x0701,
51
+ CONFIG_ENTER: 0x0704,
52
+ SINGLE_ZONE_BYPASS_WRITE: 0x074A,
50
53
  ZONE_ASSIGNMENT_CONFIG: 0x0770,
51
54
  CONFIGURATION: 0x0771,
52
55
  PARTITION_ASSIGNMENT_CONFIG: 0x0772,
@@ -104,6 +104,33 @@ export class EventHandler {
104
104
  return status;
105
105
  }
106
106
 
107
+ /**
108
+ * Update zone bypass state from 0x0820 notification
109
+ * @param {number} zoneNum - Zone number
110
+ * @param {boolean} bypassed - true if bypassed
111
+ */
112
+ handleZoneBypass(zoneNum, bypassed) {
113
+ const existing = this.zoneStates.get(zoneNum);
114
+ if (existing) {
115
+ existing.bypassed = bypassed;
116
+ existing.rawStatus = bypassed
117
+ ? (existing.rawStatus | 0x80)
118
+ : (existing.rawStatus & ~0x80);
119
+ existing.timestamp = new Date();
120
+ } else {
121
+ // Zone not yet seen — create minimal state
122
+ this.zoneStates.set(zoneNum, {
123
+ zoneNumber: zoneNum,
124
+ open: false, tamper: false, fault: false,
125
+ lowBattery: false, delinquency: false,
126
+ alarm: false, alarmInMemory: false,
127
+ bypassed: bypassed,
128
+ rawStatus: bypassed ? 0x80 : 0x00,
129
+ timestamp: new Date()
130
+ });
131
+ }
132
+ }
133
+
107
134
  /**
108
135
  * Process zone alarm notification
109
136
  */
@@ -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
- const troubles = await client.queryTroubleStatus();
149
- if (!troubles || troubles.length === 0) {
150
- console.log('No active troubles');
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
- for (const t of troubles) {
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
- client.armStay(part, code);
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
- client.armAway(part, code);
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
- client.armNight(part, code);
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,35 @@ 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
- client.disarm(part, code);
233
+ try {
234
+ const r = await client.disarm(part, code);
235
+ console.log(`Partition ${part}: ${r.modeName}`);
236
+ } catch (e) { console.log(e.message); }
237
+ break;
238
+ }
239
+
240
+ // ---- Zone Bypass ----
241
+ case 'bypass': {
242
+ const z = parseInt(args[0]);
243
+ if (!z) { console.log('Usage: bypass <zone> [part]'); break; }
244
+ const part = parseInt(args[1] || '1');
245
+ console.log(`Bypassing zone ${z}...`);
246
+ try {
247
+ const r = await client.bypassZone(z, part);
248
+ console.log(`Zone ${z}: BYPASSED${r.fromQuery ? ' (verified by query)' : ''}`);
249
+ } catch (e) { console.log(e.message); }
250
+ break;
251
+ }
252
+
253
+ case 'unbypass': {
254
+ const z = parseInt(args[0]);
255
+ if (!z) { console.log('Usage: unbypass <zone> [part]'); break; }
256
+ const part = parseInt(args[1] || '1');
257
+ console.log(`Unbypassing zone ${z}...`);
258
+ try {
259
+ const r = await client.unbypassZone(z, part);
260
+ console.log(`Zone ${z}: UNBYPASSED${r.fromQuery ? ' (verified by query)' : ''}`);
261
+ } catch (e) { console.log(e.message); }
207
262
  break;
208
263
  }
209
264
 
@@ -231,6 +286,10 @@ rl.on('line', async (line) => {
231
286
  night [part] [code] Arm partition in NIGHT mode
232
287
  disarm [part] [code] Disarm partition
233
288
 
289
+ ZONE BYPASS
290
+ bypass <zone> [part] Bypass zone (exclude from monitoring)
291
+ unbypass <zone> [part] Unbypass zone (restore monitoring)
292
+
234
293
  OTHER
235
294
  auth [code] Authenticate with master/user code
236
295
  help, ? Show this help
@@ -83,6 +83,7 @@ export const CMD = {
83
83
  SYSTEM_CAPABILITIES: 0x0613,
84
84
  PANEL_STATUS: 0x0614,
85
85
  // Configuration commands (0x07xx)
86
+ CONFIG_EXIT: 0x0701,
86
87
  CONFIG_ENTER: 0x0704,
87
88
  CONFIG_ACCESS_CODE_WRAPPER: 0x0703,
88
89
  // Module Status commands
@@ -97,6 +98,8 @@ export const CMD = {
97
98
  BUS_STATUS: 0x0816,
98
99
  TROUBLE_DETAIL: 0x0823,
99
100
  DOOR_CHIME_STATUS: 0x0819,
101
+ SINGLE_ZONE_BYPASS_STATUS: 0x0820,
102
+ SINGLE_ZONE_BYPASS_WRITE: 0x074A,
100
103
  MULTIPLE_MESSAGE: 0x0623,
101
104
  // Module Control commands
102
105
  PARTITION_ARM: 0x0900,
@@ -1191,6 +1194,59 @@ export class ITv2Session {
1191
1194
  return this.buildCommand(CMD.PARTITION_DISARM, payload);
1192
1195
  }
1193
1196
 
1197
+ /**
1198
+ * Build ENTER_CONFIGURATION_MODE (0x0704)
1199
+ * Must be sent before config write commands like 0x074A.
1200
+ * Wire format: [Partition VarBytes][Type 1B][AccessCode ByteArray(bcd)][Mode 1B]
1201
+ * @param {number} partition - Partition number (1-8)
1202
+ * @param {number} type - Programming type (3 = zone bypass)
1203
+ * @param {string} accessCode - Master/user code (e.g., "1234")
1204
+ * @param {number} mode - Access mode (1 = user code)
1205
+ */
1206
+ buildEnterConfigMode(partition, type, accessCode, mode = 1) {
1207
+ const codeStr = accessCode.toString();
1208
+ // Access code as raw digit values (each digit as one byte: "1234" → [0x01,0x02,0x03,0x04])
1209
+ const codeBytes = Buffer.from(codeStr.split('').map(d => parseInt(d, 10)));
1210
+ const payload = Buffer.concat([
1211
+ this.encodeVarBytes(partition),
1212
+ Buffer.from([type]),
1213
+ this.encodeByteArray(codeBytes),
1214
+ Buffer.from([mode]),
1215
+ ]);
1216
+
1217
+ this.log(`[Session] ENTER_CONFIG_MODE: partition=${partition}, type=${type}, mode=${mode}`);
1218
+ return this.buildCommand(CMD.CONFIG_ENTER, payload);
1219
+ }
1220
+
1221
+ /**
1222
+ * Build EXIT_CONFIGURATION_MODE (0x0701)
1223
+ * Wire format: [Partition VarBytes]
1224
+ * @param {number} partition - Partition number (1-8)
1225
+ */
1226
+ buildExitConfigMode(partition) {
1227
+ const payload = this.encodeVarBytes(partition);
1228
+ this.log(`[Session] EXIT_CONFIG_MODE: partition=${partition}`);
1229
+ return this.buildCommand(CMD.CONFIG_EXIT, payload);
1230
+ }
1231
+
1232
+ /**
1233
+ * Build SINGLE_ZONE_BYPASS_WRITE (0x074A) — sent directly inside config mode.
1234
+ * Wire format: [Partition VarBytes][ZoneNumber VarBytes][BypassState 1B]
1235
+ * @param {number} partition - Partition number (1-8)
1236
+ * @param {number} zone - Zone number
1237
+ * @param {boolean} bypass - true to bypass, false to unbypass
1238
+ */
1239
+ buildZoneBypass(partition, zone, bypass) {
1240
+ const payload = Buffer.concat([
1241
+ this.encodeVarBytes(partition),
1242
+ this.encodeVarBytes(zone),
1243
+ Buffer.from([bypass ? 0x01 : 0x00]),
1244
+ ]);
1245
+
1246
+ this.log(`[Session] ZONE_BYPASS 0x074A: partition=${partition}, zone=${zone}, bypass=${bypass}`);
1247
+ return this.buildCommand(CMD.SINGLE_ZONE_BYPASS_WRITE, payload);
1248
+ }
1249
+
1194
1250
  /**
1195
1251
  * Build command output activation (PGM trigger)
1196
1252
  * @param {number} partition - Partition number