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 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.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._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');
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._sendPacket(packet);
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
- 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,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
- 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); }
207
237
  break;
208
238
  }
209
239
 
@@ -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