dsc-itv2-client 2.0.3 → 2.0.5

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.3",
4
+ "version": "2.0.5",
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
@@ -270,6 +270,11 @@ export class ITV2Client extends EventEmitter {
270
270
  } catch (_) {}
271
271
 
272
272
  throw new Error('Arm/disarm not confirmed - panel may have open zones or troubles');
273
+ } finally {
274
+ // Arm/disarm clears zone bypass state — refresh zone status after a brief settle
275
+ setTimeout(() => {
276
+ this.queryZoneStatus().catch(() => {});
277
+ }, 2000);
273
278
  }
274
279
  }
275
280
 
@@ -309,9 +314,122 @@ export class ITV2Client extends EventEmitter {
309
314
  return this._sendArmCommand(packet, partition, 'ARMED');
310
315
  }
311
316
 
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.
317
+ /**
318
+ * Bypass a zone (exclude from monitoring).
319
+ * Uses config mode: 0x0704 0x074A 0x0701.
320
+ * @param {number} zone - Zone number to bypass
321
+ * @param {number} [partition=1] - Partition number
322
+ * @param {string} [code] - User/master code (defaults to masterCode)
323
+ * @returns {Promise<object>} Resolves with bypass confirmation
324
+ */
325
+ async bypassZone(zone, partition = 1, code) {
326
+ return this._sendBypassCommand(zone, partition, true, code);
327
+ }
328
+
329
+ /**
330
+ * Unbypass a zone (restore monitoring).
331
+ * @param {number} zone - Zone number to unbypass
332
+ * @param {number} [partition=1] - Partition number
333
+ * @param {string} [code] - User/master code (defaults to masterCode)
334
+ * @returns {Promise<object>} Resolves with unbypass confirmation
335
+ */
336
+ async unbypassZone(zone, partition = 1, code) {
337
+ return this._sendBypassCommand(zone, partition, false, code);
338
+ }
339
+
340
+ /**
341
+ * Send a zone bypass/unbypass command.
342
+ * Flow: 0x0704 (enter config mode) → wait for 0x0702 → 0x074A (bypass) → 0x0701 (exit)
343
+ * @private
344
+ */
345
+ async _sendBypassCommand(zone, partition, bypass, code, timeout = 10000) {
346
+ if (!this._checkEstablished()) {
347
+ throw new Error('Session not established');
348
+ }
349
+
350
+ const accessCode = code || this.masterCode;
351
+ const action = bypass ? 'BYPASS' : 'UNBYPASS';
352
+
353
+ // Step 1: Enter config mode (type=3 = zone bypass, mode=1 = user code)
354
+ const enterPacket = this.session.buildEnterConfigMode(partition, 3, accessCode, 1);
355
+ this._sendPacket(enterPacket);
356
+
357
+ // Step 2: Wait for 0x0702 config status confirmation (COMMAND_RESPONSE is intermediate)
358
+ await this._waitForConfigReady(5000);
359
+
360
+ // Step 3: Send zone bypass write
361
+ const bypassPacket = this.session.buildZoneBypass(partition, zone, bypass);
362
+
363
+ try {
364
+ const result = await new Promise((resolve, reject) => {
365
+ const timer = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, timeout);
366
+
367
+ const onBypass = (data) => {
368
+ if (data.zone === zone) { cleanup(); resolve(data); }
369
+ };
370
+ const cleanup = () => {
371
+ clearTimeout(timer);
372
+ this.removeListener('zone:bypass', onBypass);
373
+ };
374
+
375
+ this.on('zone:bypass', onBypass);
376
+ this._sendPacket(bypassPacket);
377
+ });
378
+
379
+ // Step 4: Exit config mode
380
+ const exitPacket = this.session.buildExitConfigMode(partition);
381
+ this._sendPacket(exitPacket);
382
+
383
+ return result;
384
+ } catch (e) {
385
+ // Always try to exit config mode
386
+ try {
387
+ const exitPacket = this.session.buildExitConfigMode(partition);
388
+ this._sendPacket(exitPacket);
389
+ } catch (_) {}
390
+
391
+ if (e.message !== 'timeout') throw e;
392
+
393
+ // No 0x0820 confirmation — query zone status to check
394
+ try {
395
+ const zones = await this.queryZoneStatus();
396
+ const zoneStatus = zones.find(z => z.zoneNumber === zone);
397
+ if (zoneStatus && zoneStatus.bypassed === bypass) {
398
+ return { zone, bypassed: bypass, fromQuery: true };
399
+ }
400
+ } catch (_) {}
401
+
402
+ throw new Error(`Zone ${zone} ${action} not confirmed - check access code and zone number`);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Wait for 0x0702 (config status) notification confirming config mode is ready.
408
+ * COMMAND_RESPONSE to 0x0704 is an intermediate ack — the real confirmation is 0x0702.
409
+ * @private
410
+ */
411
+ _waitForConfigReady(timeout = 10000) {
412
+ return new Promise((resolve) => {
413
+ const timer = setTimeout(() => { cleanup(); resolve(); }, timeout);
414
+ const origRoute = this._routePacket.bind(this);
415
+
416
+ const interceptor = (parsed, skipAck) => {
417
+ if (parsed.command === 0x0702) {
418
+ cleanup();
419
+ this._logMinimal('[Config] Config mode ready (0x0702)');
420
+ resolve();
421
+ }
422
+ origRoute(parsed, skipAck);
423
+ };
424
+
425
+ const cleanup = () => {
426
+ clearTimeout(timer);
427
+ this._routePacket = origRoute;
428
+ };
429
+
430
+ this._routePacket = interceptor;
431
+ });
432
+ }
315
433
 
316
434
  /**
317
435
  * Disarm partition
@@ -1328,6 +1446,12 @@ export class ITV2Client extends EventEmitter {
1328
1446
  const bypassed = fullPayload[offset] === 1;
1329
1447
 
1330
1448
  this._logMinimal(`[Zone ${zone.value}] ${bypassed ? 'BYPASSED' : 'UNBYPASSED'}`);
1449
+
1450
+ // Update cached zone state
1451
+ if (this.eventHandler) {
1452
+ this.eventHandler.handleZoneBypass(zone.value, bypassed);
1453
+ }
1454
+
1331
1455
  this.emit('zone:bypass', { zone: zone.value, bypassed });
1332
1456
  } catch (e) {
1333
1457
  this._log(`[Bypass Status] Parse error: ${e.message}`);
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
  */
@@ -237,6 +237,31 @@ rl.on('line', async (line) => {
237
237
  break;
238
238
  }
239
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); }
262
+ break;
263
+ }
264
+
240
265
  // ---- Auth ----
241
266
  case 'auth': {
242
267
  const code = args[0] || config.masterCode;
@@ -261,6 +286,10 @@ rl.on('line', async (line) => {
261
286
  night [part] [code] Arm partition in NIGHT mode
262
287
  disarm [part] [code] Disarm partition
263
288
 
289
+ ZONE BYPASS
290
+ bypass <zone> [part] Bypass zone (exclude from monitoring)
291
+ unbypass <zone> [part] Unbypass zone (restore monitoring)
292
+
264
293
  OTHER
265
294
  auth [code] Authenticate with master/user code
266
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
@@ -1194,48 +1195,56 @@ export class ITv2Session {
1194
1195
  }
1195
1196
 
1196
1197
  /**
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.
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
1201
  * @param {number} partition - Partition number (1-8)
1202
- * @param {number} zone - Zone number
1203
- * @param {boolean} bypass - true to bypass, false to unbypass
1202
+ * @param {number} type - Programming type (3 = zone bypass)
1204
1203
  * @param {string} accessCode - Master/user code (e.g., "1234")
1204
+ * @param {number} mode - Access mode (1 = user code)
1205
1205
  */
1206
- buildZoneBypass(partition, zone, bypass, accessCode) {
1207
- // Inner 0x074A payload: [Partition VarBytes][ZoneNumber VarBytes][BypassState 1B]
1208
- const innerPayload = Buffer.concat([
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([
1209
1211
  this.encodeVarBytes(partition),
1210
- this.encodeVarBytes(zone),
1211
- Buffer.from([bypass ? 0x01 : 0x00]),
1212
+ Buffer.from([type]),
1213
+ this.encodeByteArray(codeBytes),
1214
+ Buffer.from([mode]),
1212
1215
  ]);
1213
1216
 
1214
- // Access code in BCD format
1215
- const bcdCode = this._encodeBcd(accessCode);
1217
+ this.log(`[Session] ENTER_CONFIG_MODE: partition=${partition}, type=${type}, mode=${mode}`);
1218
+ return this.buildCommand(CMD.CONFIG_ENTER, payload);
1219
+ }
1216
1220
 
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);
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
+ }
1220
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) {
1221
1240
  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
1241
+ this.encodeVarBytes(partition),
1242
+ this.encodeVarBytes(zone),
1243
+ Buffer.from([bypass ? 0x01 : 0x00]),
1225
1244
  ]);
1226
1245
 
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);
1246
+ this.log(`[Session] ZONE_BYPASS 0x074A: partition=${partition}, zone=${zone}, bypass=${bypass}`);
1247
+ return this.buildCommand(CMD.SINGLE_ZONE_BYPASS_WRITE, payload);
1239
1248
  }
1240
1249
 
1241
1250
  /**