dsc-itv2-client 2.0.3 → 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.3",
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
@@ -309,9 +309,122 @@ export class ITV2Client extends EventEmitter {
309
309
  return this._sendArmCommand(packet, partition, 'ARMED');
310
310
  }
311
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.
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
+ });
427
+ }
315
428
 
316
429
  /**
317
430
  * Disarm partition
@@ -1328,6 +1441,12 @@ export class ITV2Client extends EventEmitter {
1328
1441
  const bypassed = fullPayload[offset] === 1;
1329
1442
 
1330
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
+
1331
1450
  this.emit('zone:bypass', { zone: zone.value, bypassed });
1332
1451
  } catch (e) {
1333
1452
  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
  /**