homebridge-texecom-full 4.3.1-beta.3 → 4.4.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  This change log documents all release versions of homebridge-texecom
4
4
 
5
+ ### 4.4.0 (2026-10-07)
6
+
7
+ Rolls up the 4.3.1 and 4.4.0 betas below. Coming from 4.2.8, existing accessories carry over with their rooms, names and automations. Coming from 4.3.0, turn on `external_accessories` to keep the accessories that release published outside the bridge, see 4.3.1-beta.3.
8
+
9
+ - **FEATURE** - Combined areas. One accessory can arm and disarm several areas at once, set up under `area_groups`.
10
+ - **FEATURE** - Panel clock sync. `time_sync_interval` sets how often, in hours, the panel clock is checked against the machine running Homebridge and corrected. Off by default.
11
+ - **FEATURE** - `external_accessories` lets installs that ran 4.3.0 keep their accessories.
12
+ - **FEATURE** - How an arm is reported to HomeKit is configurable via `remote_users`, `app_users` and `default_arm_state` (#25).
13
+ - **FIX** - Accessories are bridged again, as in 4.2.8 and earlier, and are cached across restarts.
14
+ - **FIX** - Arming or disarming areas 5 to 8 addressed the wrong areas, and area 8 sent a malformed command. Present since 4.2.8.
15
+ - **FIX** - The first arm or disarm from a HomeKit button is no longer ignored (#26).
16
+ - **FIX** - A full arm no longer changes to Night in the Home app a minute later (#25).
17
+ - **FIX** - The arm/disarm command is retried once if the panel ignores it, and the retry timer is cleared once the panel answers.
18
+ - **FIX** - Areas and zones sharing a number no longer share a serial number.
19
+ - **TWEAK** - Runs on Homebridge 1.x again as well as 2.x.
20
+
21
+ ### 4.4.0-beta.1 (2026-08-26)
22
+
23
+ - **FEATURE** - Panel clock sync. Set `time_sync_interval` to the number of hours between checks and the plugin reads the panel clock, comparing it against the machine running Homebridge, and sets it when the two have drifted apart. Anything from every hour to once every 31 days, 0 leaves the panel clock alone and is the default. The panel is also checked shortly after Homebridge connects, so a panel that lost its clock in a power cut is put right without waiting for the interval. Needs a UDL, as arming does.
24
+ - **FIX** - Commands are written a byte at a time rather than as text. Arming or disarming area 8, or any combined area including it, sent the bitmask as two bytes because the byte the panel wanted was 0x80 and the default encoding turns anything from 128 up into a pair. The panel would have read that as a different command. Areas 1 to 7 on their own were unaffected. Present since 4.2.8.
25
+
26
+ ### 4.4.0-beta.0 (2026-08-19)
27
+
28
+ - **FEATURE** - Combined areas. One accessory can arm and disarm several areas at once, set up under `area_groups`. The panel addresses areas as a bitmask, so they go in a single command rather than one after another. A combined area reports armed only once every one of its areas is armed, showing as arming until then, and an alarm in any of them shows through straight away. The areas keep their own accessories, and an area can belong to more than one combined area.
29
+
5
30
  ### 4.3.1-beta.3 (2026-08-19)
6
31
 
7
32
  - **FEATURE** - `external_accessories` for installs that ran 4.3.0. That release published accessories outside the bridge, and HomeKit gives no way to move a standalone accessory onto a bridge without it counting as a new accessory, so the scenes, buttons and automations built on them would all have to be set up again. Turning this on republishes them the way 4.3.0 did, with the same identities, and they carry on working. Off by default, so a fresh install or one coming from 4.2.8 gets bridged accessories.
package/README.md CHANGED
@@ -108,6 +108,24 @@ When an area is armed the panel reports the area and the user number, but not wh
108
108
 
109
109
  When the arm was started from HomeKit the plugin already knows what was asked for and reports that, whatever the user number. These settings only come into play for an arm that started somewhere else.
110
110
 
111
+ ### Panel Clock
112
+
113
+ The panel keeps its own clock, and nothing corrects it once it drifts apart from an engineer at the keypad. This checks it against the clock on the machine running Homebridge and sets it when the two have come apart.
114
+
115
+ | Key | Default | Description |
116
+ | --- | --- | --- |
117
+ | `time_sync_interval` | 0 | How often to check the panel clock, in hours. `1` checks every hour, `24` once a day, `744` once every 31 days, and anything in between works. `0` turns it off and leaves the panel clock alone. |
118
+
119
+ ```json
120
+ "time_sync_interval": 24
121
+ ```
122
+
123
+ It is off unless you set it. A UDL is needed, the same one arming uses, because the panel asks to be logged into before it will give up its clock or take a new time.
124
+
125
+ The panel is checked shortly after Homebridge connects to it as well as on the interval, so a panel that lost its clock in a power cut is put right without waiting for the next check. A difference of a minute is left alone: the panel only counts whole minutes, so a check landing either side of a minute boundary can look a minute out when it is really in step. Anything beyond that is set. The panel carries a two digit year, so it is read as being in this century.
126
+
127
+ Once a day is plenty for a panel that keeps reasonable time. Checking every hour is there for a panel that drifts badly, and costs two short commands each time.
128
+
111
129
  ### Upgrading from 4.3.0
112
130
 
113
131
  **Only relevant if you ran 4.3.0.** Skip this if you came from 4.2.8 or earlier, or are installing fresh.
@@ -125,6 +143,34 @@ If you set all that up under 4.3.0 and would rather keep it:
125
143
  That republishes them exactly as 4.3.0 did, with the same identities, so everything keeps working untouched. The trade-off is that they stay outside the bridge, so a zone added later still has to be paired by hand.
126
144
 
127
145
  Leaving it off, or leaving it out entirely, gives bridged accessories.
146
+ ### Combined Areas
147
+
148
+ A combined area is a single accessory that arms or disarms several areas at once. The panel addresses areas as a bitmask, so all of them are armed with one command rather than several in a row.
149
+
150
+ ```json
151
+ "area_groups": [
152
+ {
153
+ "name": "Everything",
154
+ "areas": [1, 2, 3, 4, 5],
155
+ "dwell": 0
156
+ },
157
+ {
158
+ "name": "House and Garage",
159
+ "areas": [1, 2],
160
+ "dwell": 0
161
+ }
162
+ ]
163
+ ```
164
+
165
+ | Key | Default | Description |
166
+ | --- | --- | --- |
167
+ | `name` | N/A | The name of the combined area as it will appear in HomeKit |
168
+ | `areas` | N/A | The area numbers to arm and disarm together. The panel supports areas 1 to 8 |
169
+ | `dwell` | 0 | The amount of time in ms before a cleared state is applied |
170
+
171
+ The areas can also still be listed under `areas` and keep their own accessories, and an area may belong to more than one combined area.
172
+
173
+ A combined area shows as armed only once every one of its areas is armed, so it never reports the house as set while part of it is still open. Until they are all armed HomeKit shows it arming. An alarm in any one of its areas shows through immediately.
128
174
 
129
175
  ### Per-zone Configuration
130
176
 
@@ -0,0 +1,36 @@
1
+ ## Highlights
2
+
3
+ - **Combined areas.** One accessory can arm and disarm several areas at once, set up under `area_groups`. The areas go to the panel in a single command. A combined area shows as armed once all its areas are armed, and an alarm in any of them shows straight away.
4
+ - **Panel clock sync.** Set `time_sync_interval` to the number of hours between checks, from 1 up to 744 (31 days). The plugin compares the panel clock with the machine running Homebridge and corrects any drift. It also checks shortly after connecting, so a panel that lost its time in a power cut is put right. Off by default. Needs a UDL, as arming does.
5
+ - **Configurable arm reporting.** `remote_users`, `app_users` and `default_arm_state` decide how an arm is reported to HomeKit, instead of user numbers fixed in code (#25).
6
+
7
+ ## Upgrading
8
+
9
+ - **From 4.2.8 or earlier:** nothing to do. Accessories carry over with their rooms, names and automations.
10
+ - **From 4.3.0:** 4.3.0 published accessories outside the bridge. To keep them and the scenes and automations built on them, turn on `external_accessories`. Leave it off to get bridged accessories, which will need setting up again in the Home app.
11
+
12
+ ## Fixes
13
+
14
+ - Accessories are bridged again, as in 4.2.8 and earlier, and are cached across restarts. Accessories removed from the config are unregistered.
15
+ - Arming or disarming areas 5 to 8 addressed the wrong areas, and area 8 sent a malformed command. Present since 4.2.8.
16
+ - The first arm or disarm from a HomeKit button is no longer ignored (#26).
17
+ - A full arm no longer changes to Night in the Home app a minute later (#25).
18
+ - The arm/disarm command is retried once when the panel ignores the first write, and the retry timer is cleared once the panel answers. Before, every command also sent a second login two seconds later.
19
+ - Areas and zones that share a number no longer share a serial number.
20
+ - User numbers over 99 are no longer truncated.
21
+ - Areas are matched by area number rather than by their position in the config.
22
+
23
+ ## Other
24
+
25
+ - Runs on Homebridge 1.x again as well as 2.x.
26
+ - Removed the unused `crypto-js` dependency.
27
+
28
+ ## Contributors
29
+
30
+ - @K1LL3R234 - development and maintenance of this release
31
+ - @badgertastic - reported and helped test #25 and #26
32
+ - @max-christian - original author of homebridge-texecom
33
+ - @kieranmjones - code contributions
34
+ - @garethflowers - code contributions
35
+
36
+ Full details in the [changelog](https://github.com/K1LL3R234/homebridge-texecom/blob/master/CHANGELOG.md).
@@ -46,6 +46,14 @@
46
46
  "maxLength": 6,
47
47
  "title": "UDL"
48
48
  },
49
+ "time_sync_interval": {
50
+ "type": "integer",
51
+ "default": 0,
52
+ "minimum": 0,
53
+ "maximum": 744,
54
+ "title": "Panel Clock Sync (hours)",
55
+ "description": "How often to check the panel clock against the clock on this machine, in hours, and set it if it has drifted. 1 checks every hour, 24 once a day, 744 once every 31 days. 0 turns it off and leaves the panel clock alone. Needs a UDL."
56
+ },
49
57
  "remote_users": {
50
58
  "type": "array",
51
59
  "title": "Remote / Keyfob Users",
@@ -161,6 +169,40 @@
161
169
  "default": false,
162
170
  "description": "Only for installs that ran 4.3.0. That release published every zone and area outside the bridge, so they had to be added to the Home app by hand. Turn this on to keep publishing them that way, with the same identities, so the scenes, buttons and automations built on them keep working. Leave it off otherwise."
163
171
  },
172
+ "area_groups": {
173
+ "type": "array",
174
+ "title": "Combined Areas",
175
+ "description": "One accessory that arms or disarms several areas together. The panel takes them all in a single command.",
176
+ "items": {
177
+ "type": "object",
178
+ "properties": {
179
+ "name": {
180
+ "type": "string",
181
+ "title": "Name"
182
+ },
183
+ "areas": {
184
+ "type": "array",
185
+ "title": "Areas",
186
+ "description": "The area numbers to arm and disarm together, for example 1 and 2.",
187
+ "items": {
188
+ "type": "integer",
189
+ "minimum": 1,
190
+ "maximum": 8,
191
+ "title": "Area Number"
192
+ }
193
+ },
194
+ "dwell": {
195
+ "type": "integer",
196
+ "minimum": 0,
197
+ "title": "Dwell Time"
198
+ }
199
+ },
200
+ "required": [
201
+ "name",
202
+ "areas"
203
+ ]
204
+ }
205
+ },
164
206
  "debug":{
165
207
  "type":"boolean",
166
208
  "title":"Debug",
package/index.js CHANGED
@@ -45,6 +45,10 @@ class TexecomPlatform {
45
45
  // to a bridged one, whatever we do at this end.
46
46
  this.external_accessories = config["external_accessories"] === true;
47
47
 
48
+ // Combined areas: one accessory that arms or disarms several areas
49
+ // at once. The panel takes them all in a single command.
50
+ this.area_groups = config["area_groups"] || [];
51
+
48
52
  // Which panel user numbers mean what when the panel reports an area as
49
53
  // armed. The panel does not say whether that was a full or a part arm,
50
54
  // so the user number is the only hint we get.
@@ -52,6 +56,10 @@ class TexecomPlatform {
52
56
  this.app_users = parseUserList(config["app_users"]);
53
57
  this.default_arm_state = String(config["default_arm_state"] || "away").toLowerCase();
54
58
 
59
+ // How often to check the panel clock against this machine's, in hours.
60
+ // 0 leaves the panel clock alone.
61
+ this.time_sync_interval = parseSyncInterval(config["time_sync_interval"]);
62
+
55
63
  // Accessories restored from the Homebridge cache, keyed by UUID.
56
64
  this.cachedAccessories = new Map();
57
65
 
@@ -63,6 +71,10 @@ class TexecomPlatform {
63
71
  // Called once for every accessory Homebridge has cached for this platform,
64
72
  // before didFinishLaunching. Hold on to them so they are reused rather than
65
73
  // recreated, which is what keeps them in place in the Home app.
74
+ findAreaAccessory(area_number) {
75
+ return (this._areaAccessories || []).find(a => Number(a.zone_number) === Number(area_number));
76
+ }
77
+
66
78
  configureAccessory(accessory) {
67
79
  this.log.debug(`Restoring cached accessory ${accessory.displayName}`);
68
80
  this.cachedAccessories.set(accessory.UUID, accessory);
@@ -72,11 +84,52 @@ class TexecomPlatform {
72
84
  const platform = this;
73
85
  const { hap, api } = this;
74
86
 
75
- const zoneAccessories = this.zones.map(z => new TexecomAccessory(this.log, z, hap));
76
- const areaAccessories = this.areas.map(a => new TexecomAccessory(this.log, a, hap));
87
+ const zoneAccessories = this.zones.map(z => new TexecomAccessory(this.log, z, hap, "zone"));
88
+ const areaAccessories = this.areas.map(a => new TexecomAccessory(this.log, a, hap, "area"));
89
+ const groupAccessories = this.area_groups.map(g => new TexecomAccessory(this.log, g, hap, "group"));
77
90
  const zoneCount = zoneAccessories.length;
78
91
  const areaCount = areaAccessories.length;
79
92
 
93
+ this._areaAccessories = areaAccessories;
94
+
95
+ // The last state we heard for each area, whether or not that area has
96
+ // an accessory of its own. Combined areas are worked out from this.
97
+ const areaStates = new Map();
98
+
99
+ // A combined area counts as armed only when every one of its areas is
100
+ // armed, so it never claims to be set while part of it is open. An
101
+ // alarm in any one of them shows through regardless.
102
+ function refreshGroups() {
103
+ const Characteristic = hap.Characteristic;
104
+
105
+ groupAccessories.forEach(group => {
106
+ const states = group.area_numbers.map(n => areaStates.get(n));
107
+
108
+ if (states.some(s => s === Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED)) {
109
+ group.changeHandler(Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED);
110
+ return;
111
+ }
112
+
113
+ const armed = states.filter(s => s !== undefined && s !== Characteristic.SecuritySystemCurrentState.DISARMED);
114
+ if (states.length === 0 || armed.length !== states.length) {
115
+ group.changeHandler(Characteristic.SecuritySystemCurrentState.DISARMED);
116
+ return;
117
+ }
118
+
119
+ // Everything is armed. Use what was asked for from HomeKit if we
120
+ // know it, then a state the areas agree on, otherwise call it away.
121
+ var stateValue;
122
+ if (group.pending_target_state != null) {
123
+ stateValue = targetToCurrentState(Characteristic, group.pending_target_state);
124
+ } else if (armed.every(s => s === armed[0])) {
125
+ stateValue = armed[0];
126
+ } else {
127
+ stateValue = Characteristic.SecuritySystemCurrentState.AWAY_ARM;
128
+ }
129
+ group.changeHandler(stateValue);
130
+ });
131
+ }
132
+
80
133
  const activeUUIDs = new Set();
81
134
 
82
135
  // Accessories are published through the Homebridge bridge, so they turn
@@ -117,7 +170,9 @@ class TexecomPlatform {
117
170
 
118
171
  // Reproduces 4.3.0 exactly: the same UUID, from the same serial number,
119
172
  // so Homebridge gives the accessory back the pairing it already had and
120
- // the Home app carries on as though nothing happened.
173
+ // the Home app carries on as though nothing happened. A combined area
174
+ // is new in 4.4.0, so it has no 4.3.0 identity to preserve, but it is
175
+ // published the same way so the whole install stays off the bridge.
121
176
  const publishExternal = (acc, typePrefix) => {
122
177
  const uuid = hap.uuid.generate(`${typePrefix}:${acc.legacy_sn}`);
123
178
  const hapAccessory = new api.platformAccessory(acc.name, uuid);
@@ -133,6 +188,7 @@ class TexecomPlatform {
133
188
 
134
189
  zoneAccessories.forEach(acc => publishExternal(acc, "zone"));
135
190
  areaAccessories.forEach(acc => publishExternal(acc, "area"));
191
+ groupAccessories.forEach(acc => publishExternal(acc, "group"));
136
192
 
137
193
  // Nothing is on the bridge in this mode, so anything left in the
138
194
  // cache from a bridged run is no longer being served by us and
@@ -147,6 +203,7 @@ class TexecomPlatform {
147
203
  } else {
148
204
  zoneAccessories.forEach(attachAccessory);
149
205
  areaAccessories.forEach(attachAccessory);
206
+ groupAccessories.forEach(attachAccessory);
150
207
 
151
208
  // Anything cached that is no longer in the config has to go, otherwise
152
209
  // it lingers in the Home app as an unresponsive accessory.
@@ -189,6 +246,8 @@ class TexecomPlatform {
189
246
  var stateValue = hap.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED;
190
247
  platform.log.log(`Area ${areaAccessories[a].zone_number} manual triggered`);
191
248
  areaAccessories[a].changeHandler(stateValue);
249
+ areaStates.set(Number(areaAccessories[a].zone_number), stateValue);
250
+ refreshGroups();
192
251
  }
193
252
  });
194
253
  } catch (e) {
@@ -234,6 +293,11 @@ class TexecomPlatform {
234
293
  if (area) {
235
294
  area.pending_target_state = null;
236
295
  }
296
+ groupAccessories.forEach(g => {
297
+ if (g.area_numbers.includes(updated_area)) {
298
+ g.pending_target_state = null;
299
+ }
300
+ });
237
301
  break;
238
302
 
239
303
  case "A":
@@ -262,6 +326,9 @@ class TexecomPlatform {
262
326
  area.changeHandler(stateValue, user);
263
327
  }
264
328
 
329
+ areaStates.set(updated_area, stateValue);
330
+ refreshGroups();
331
+
265
332
  } else {
266
333
  platform.log.debug(`Unknown string from Texecom: ${S(data)}`);
267
334
  }
@@ -274,10 +341,14 @@ class TexecomPlatform {
274
341
  var connection = net.createConnection(platform.ip_port, platform.ip_address);
275
342
  connection.setNoDelay(true);
276
343
 
277
- connection.on('connect', () => platform.log.log('Connected via IP'));
344
+ connection.on('connect', () => {
345
+ platform.log.log('Connected via IP');
346
+ platform._panelConnected();
347
+ });
278
348
 
279
349
  connection.on('data', function (data) {
280
350
  platform.log.debug(`IP data received: ${data}`);
351
+ responseEmitter.emit('raw', data);
281
352
  responseEmitter.emit('data', data);
282
353
  processData(data);
283
354
  });
@@ -305,9 +376,17 @@ class TexecomPlatform {
305
376
  const sp = new SerialPort({ path: this.serial_device, baudRate: this.baud_rate });
306
377
  const parser = sp.pipe(new ReadlineParser({ delimiter: '\n' }));
307
378
 
308
- sp.on("open", () => platform.log.log("Serial port opened"));
379
+ sp.on("open", () => {
380
+ platform.log.log("Serial port opened");
381
+ platform._panelConnected();
382
+ });
309
383
  sp.on("error", (err) => platform.log.error("Serial port error:", err.message));
310
384
 
385
+ // The parser splits on 0x0A, which the panel clock reply can carry
386
+ // inside it - the 10th of a month, or ten minutes past. Reading the
387
+ // clock works off the bytes as they arrive instead.
388
+ sp.on('data', (data) => responseEmitter.emit('raw', data));
389
+
311
390
  parser.on('data', function (data) {
312
391
  platform.log.debug(`Serial data received: ${data}`);
313
392
  responseEmitter.emit('data', data);
@@ -322,6 +401,110 @@ class TexecomPlatform {
322
401
  } else {
323
402
  this.log.log("Must set either serial_device or ip_address in configuration.");
324
403
  }
404
+
405
+ this._startClockSync();
406
+ }
407
+
408
+ // ── Panel clock ──────────────────────────────────────────────────────────
409
+ _startClockSync() {
410
+ if (this.time_sync_interval <= 0) {
411
+ this.log.debug("Panel clock sync is off");
412
+ return;
413
+ }
414
+
415
+ if (this.udl == null) {
416
+ this.log.log("Panel clock sync needs a UDL. Add one to the config, or set the sync interval to 0 to turn it off.");
417
+ this.time_sync_interval = 0;
418
+ return;
419
+ }
420
+
421
+ this.log.log(`Checking the panel clock every ${describeInterval(this.time_sync_interval)}`);
422
+
423
+ // The next check is timed from the end of the last one rather than by a
424
+ // repeating interval, so a check that is slow to answer cannot have the
425
+ // next one land on top of it.
426
+ const period = this.time_sync_interval * 60 * 60 * 1000;
427
+ const again = () => scheduleAfter(period, () => this._syncPanelClock().then(again));
428
+ again();
429
+ }
430
+
431
+ // The panel is checked shortly after the connection comes up as well as on
432
+ // the interval, so a panel that lost its clock in a power cut is put right
433
+ // without waiting for the next check. A dropped IP connection reconnects, so
434
+ // this can run again; the pending check is replaced rather than stacked up.
435
+ _panelConnected() {
436
+ if (this.time_sync_interval <= 0) {
437
+ return;
438
+ }
439
+
440
+ if (this._clock_sync_pending) {
441
+ clearTimeout(this._clock_sync_pending);
442
+ }
443
+
444
+ this._clock_sync_pending = setTimeout(() => this._syncPanelClock(), 5000);
445
+ if (this._clock_sync_pending.unref) {
446
+ this._clock_sync_pending.unref();
447
+ }
448
+ }
449
+
450
+ _syncPanelClock() {
451
+ const platform = this;
452
+
453
+ if (this._clock_sync_running) {
454
+ this.log.debug("Panel clock check still running, skipping this one");
455
+ return Promise.resolve();
456
+ }
457
+
458
+ const connection = this.texecomConnection;
459
+ if (!connection) {
460
+ this.log.debug("Not connected to the panel, skipping the clock check");
461
+ return Promise.resolve();
462
+ }
463
+
464
+ this._clock_sync_running = true;
465
+
466
+ return writeCommandAndWaitForOK(connection, `W${this.udl}`)
467
+ .then(() => readPanelTime(connection))
468
+ .then(panel => {
469
+ const now = new Date();
470
+ const drift = clockDriftMinutes(panel, now);
471
+
472
+ platform._clock_sync_failing = false;
473
+
474
+ if (Math.abs(drift) <= CLOCK_TOLERANCE_MINUTES) {
475
+ platform.log.debug(`Panel clock reads ${formatPanelTime(panel)}, in step`);
476
+ return;
477
+ }
478
+
479
+ if (shouldDeferClockSet(now)) {
480
+ platform.log.debug(`Panel clock is ${describeDrift(drift)}, waiting a minute to set it: 47 minutes past would put a framing character inside the command`);
481
+ scheduleAfter(61000, () => platform._syncPanelClock());
482
+ return;
483
+ }
484
+
485
+ platform.log.log(`Panel clock reads ${formatPanelTime(panel)}, ${describeDrift(drift)}. Setting it to ${formatPanelTime(localPanelTime(now))}`);
486
+
487
+ // Logging in again rather than leaning on the session opened for
488
+ // the read, which is what the panel asks for and costs one
489
+ // command at most once an interval.
490
+ return writeCommandAndWaitForOK(connection, `W${platform.udl}`)
491
+ .then(() => writeCommandAndWaitForOK(connection, panelTimeCommand(now)))
492
+ .then(() => platform.log.log("Panel clock set"));
493
+ })
494
+ .catch(err => {
495
+ // Said once when it starts going wrong rather than on every
496
+ // pass, so a panel that cannot be reached does not fill the log
497
+ // with the same line for as long as it is left running.
498
+ if (platform._clock_sync_failing) {
499
+ platform.log.debug(`Panel clock check failed: ${err.message}`);
500
+ } else {
501
+ platform._clock_sync_failing = true;
502
+ platform.log.log(`Panel clock check failed: ${err.message}. Further failures are only logged with debug on.`);
503
+ }
504
+ })
505
+ .then(() => {
506
+ platform._clock_sync_running = false;
507
+ });
325
508
  }
326
509
  }
327
510
 
@@ -421,16 +604,28 @@ function normaliseUser(value) {
421
604
  }
422
605
 
423
606
  // ─── Accessory ───────────────────────────────────────────────────────────────
424
- function TexecomAccessory(log, config, hap) {
607
+ const MODELS = { zone: "Zone", area: "Area", group: "Combined Area" };
608
+
609
+ function TexecomAccessory(log, config, hap, kind) {
425
610
  this.log = log;
426
611
  this.hap = hap;
612
+ this.kind = kind || "zone";
427
613
 
428
- this.zone_number = zpad(config["zone_number"] || config["area_number"], 3);
429
614
  this.name = config["name"];
430
- this.zone_type = config["zone_type"] || config["area_type"] || "motion";
431
615
  this.dwell_time = config["dwell"] || 0;
432
616
  this.dwell_timer = null;
433
617
 
618
+ if (this.kind === "group") {
619
+ // A combined area is addressed as several area numbers at once.
620
+ this.area_numbers = (config["areas"] || []).map(Number).filter(n => !Number.isNaN(n));
621
+ this.zone_number = this.area_numbers.join("+");
622
+ this.zone_type = "securitysystem";
623
+ } else {
624
+ this.zone_number = zpad(config["zone_number"] || config["area_number"], 3);
625
+ this.zone_type = config["zone_type"] || config["area_type"] || "motion";
626
+ this.area_numbers = this.zone_type === "securitysystem" ? [Number(this.zone_number)] : [];
627
+ }
628
+
434
629
  // Replaced with the real handler once the accessory has been published.
435
630
  this.changeHandler = function () { };
436
631
 
@@ -465,7 +660,8 @@ function TexecomAccessory(log, config, hap) {
465
660
  // them the same serial number. Only the area side is namespaced so
466
661
  // that zone serial numbers stay as they have always been.
467
662
  const shasum = crypto.createHash('sha1');
468
- shasum.update(this.zone_type === "securitysystem" ? `area:${this.zone_number}` : this.zone_number);
663
+ shasum.update(this.kind === "group" ? `group:${this.zone_number}`
664
+ : this.zone_type === "securitysystem" ? `area:${this.zone_number}` : this.zone_number);
469
665
  this.sn = shasum.digest('base64');
470
666
  log.log(`Computed SN: ${this.sn}`);
471
667
  }
@@ -485,7 +681,7 @@ TexecomAccessory.prototype = {
485
681
  hapAccessory.getService(Service.AccessoryInformation)
486
682
  .setCharacteristic(Characteristic.Name, this.name)
487
683
  .setCharacteristic(Characteristic.Manufacturer, "Homebridge")
488
- .setCharacteristic(Characteristic.Model, `Texecom ${this.zone_type === "securitysystem" ? "Area" : "Zone"}`)
684
+ .setCharacteristic(Characteristic.Model, `Texecom ${MODELS[this.kind] || "Zone"}`)
489
685
  .setCharacteristic(Characteristic.SerialNumber, serialNumber || this.sn);
490
686
 
491
687
  var ServiceType, changeAction;
@@ -613,7 +809,7 @@ TexecomAccessory.prototype = {
613
809
  function areaTargetSecurityStateSet(platform, accessory, service, value, callback) {
614
810
  const { Characteristic } = platform.hap;
615
811
 
616
- const mask = areaMask([Number(accessory.zone_number)]);
812
+ const mask = areaMask(accessory.area_numbers);
617
813
  if (mask === null) {
618
814
  platform.log.error(`Area ${accessory.zone_number} is out of range, the panel only addresses areas 1 to 8`);
619
815
  callback(new Error("Area out of range"));
@@ -654,12 +850,29 @@ function areaTargetSecurityStateSet(platform, accessory, service, value, callbac
654
850
  return;
655
851
  }
656
852
  platform.log.debug(`Area ${accessory.zone_number} → state ${currentState}`);
657
- service.getCharacteristic(Characteristic.SecuritySystemCurrentState).updateValue(currentState);
853
+
854
+ // A combined area waits for each of its areas to report in, so HomeKit
855
+ // shows it arming until they all are. Updating it here instead would
856
+ // make it flick between armed and disarmed as they come in one by one.
857
+ if (accessory.kind !== "group") {
858
+ service.getCharacteristic(Characteristic.SecuritySystemCurrentState).updateValue(currentState);
859
+ }
658
860
 
659
861
  // Remember what was asked for. The panel reports the area as armed
660
862
  // once the exit delay has run, without saying how it was armed, and
661
863
  // this is what stops that report being taken for a part arm.
662
- accessory.pending_target_state = (value === Characteristic.SecuritySystemTargetState.DISARM) ? null : value;
864
+ const pending = (value === Characteristic.SecuritySystemTargetState.DISARM) ? null : value;
865
+ accessory.pending_target_state = pending;
866
+
867
+ // A combined area asked on behalf of several areas, so their own
868
+ // accessories need to know too, otherwise they fall back to the
869
+ // configured default when the panel reports them armed.
870
+ accessory.area_numbers.forEach(n => {
871
+ const member = platform.findAreaAccessory(n);
872
+ if (member && member !== accessory) {
873
+ member.pending_target_state = pending;
874
+ }
875
+ });
663
876
  callback();
664
877
  })
665
878
  .catch(err => {
@@ -697,7 +910,7 @@ function writeCommandAndWaitForOK(connection, command, retryCount = 1) {
697
910
  }
698
911
  }, 2000);
699
912
 
700
- connection.write(`\\${command}/`, function (err) {
913
+ connection.write(frameCommand(command), function (err) {
701
914
  if (err) {
702
915
  settle(reject, err);
703
916
  }
@@ -724,3 +937,220 @@ function areaMask(area_numbers) {
724
937
  function is_armed(area_number) {
725
938
  return areas_armed.some(v => v === area_number);
726
939
  }
940
+
941
+ function toBuffer(data) {
942
+ return Buffer.isBuffer(data) ? data : Buffer.from(String(data), "latin1");
943
+ }
944
+
945
+ // Wraps a command in the \ and / the panel expects. Commands are written as
946
+ // latin1 so that a byte the command carries goes out as that one byte: the area
947
+ // bitmask and the date and time are numbers rather than text, and the default
948
+ // encoding would send anything from 128 up as two bytes.
949
+ function frameCommand(command) {
950
+ return Buffer.concat([Buffer.from("\\", "latin1"), toBuffer(command), Buffer.from("/", "latin1")]);
951
+ }
952
+
953
+ // ─── Panel clock ──────────────────────────────────────────────────────────────
954
+ // The panel keeps its own clock and drifts, and nothing corrects it apart from
955
+ // an engineer at the keypad. When time_sync_interval is set the plugin reads the
956
+ // panel clock on that interval and writes the current time back when the two
957
+ // have come apart. Both commands need a UDL login first, exactly as arming does.
958
+ //
959
+ // read : \T?/ → B1..B5 0x0D 0x0A, or ERROR 0x0D 0x0A
960
+ // set : \T B1..B5/ → OK 0x0D 0x0A, or ERROR 0x0D 0x0A
961
+ //
962
+ // B1..B5 are day, month, two digit year, hours and minutes, sent as raw bytes
963
+ // rather than as text.
964
+
965
+ // The panel clock only counts whole minutes, so a read that lands either side of
966
+ // a minute boundary can look a minute out when it is in step. Anything beyond
967
+ // that is real drift.
968
+ const CLOCK_TOLERANCE_MINUTES = 1;
969
+
970
+ // The longest an interval can be, in hours: 31 days.
971
+ const MAX_SYNC_INTERVAL_HOURS = 744;
972
+
973
+ // A timer cannot be given a delay beyond this, roughly 24.8 days. Anything
974
+ // larger wraps round and fires immediately, so a longer wait is walked down in
975
+ // steps rather than asked for in one go.
976
+ const MAX_TIMER_MS = 2147483647;
977
+
978
+ // Hours, 0 (or anything unusable) turns the sync off, capped at 31 days.
979
+ function parseSyncInterval(value) {
980
+ if (value === null || value === undefined || value === "") {
981
+ return 0;
982
+ }
983
+ const hours = Math.round(Number(value));
984
+ if (!Number.isFinite(hours) || hours <= 0) {
985
+ return 0;
986
+ }
987
+ return Math.min(hours, MAX_SYNC_INTERVAL_HOURS);
988
+ }
989
+
990
+ function describeInterval(hours) {
991
+ if (hours >= 24 && hours % 24 === 0) {
992
+ const days = hours / 24;
993
+ return `${days} day${days === 1 ? "" : "s"}`;
994
+ }
995
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
996
+ }
997
+
998
+ // setTimeout with a delay a timer can actually hold, however long the wait.
999
+ function scheduleAfter(delayMs, run) {
1000
+ const step = Math.min(delayMs, MAX_TIMER_MS);
1001
+ const timer = setTimeout(() => {
1002
+ const remaining = delayMs - step;
1003
+ if (remaining > 0) {
1004
+ scheduleAfter(remaining, run);
1005
+ } else {
1006
+ run();
1007
+ }
1008
+ }, step);
1009
+
1010
+ if (timer.unref) {
1011
+ timer.unref();
1012
+ }
1013
+ return timer;
1014
+ }
1015
+
1016
+ // Reads the panel clock. The reply is seven raw bytes, and the panel can report
1017
+ // a zone or area at any moment, so the answer is picked out of whatever arrived
1018
+ // rather than assumed to be the next thing on the wire.
1019
+ function readPanelTime(connection, retryCount = 1) {
1020
+ return new Promise((resolve, reject) => {
1021
+ var buffer = Buffer.alloc(0);
1022
+
1023
+ function settle(fn, arg) {
1024
+ clearTimeout(timer);
1025
+ responseEmitter.removeListener('raw', handleRaw);
1026
+ fn(arg);
1027
+ }
1028
+
1029
+ function handleRaw(data) {
1030
+ buffer = Buffer.concat([buffer, toBuffer(data)]);
1031
+
1032
+ // Only the tail can hold the reply, the rest is panel chatter that
1033
+ // arrived while we were waiting.
1034
+ if (buffer.length > 64) {
1035
+ buffer = buffer.subarray(buffer.length - 64);
1036
+ }
1037
+
1038
+ if (buffer.includes("ERROR")) {
1039
+ settle(reject, new Error("the panel refused the request, check the UDL"));
1040
+ return;
1041
+ }
1042
+
1043
+ const reply = findPanelTimeReply(buffer);
1044
+ if (reply) {
1045
+ settle(resolve, reply);
1046
+ }
1047
+ }
1048
+
1049
+ responseEmitter.on('raw', handleRaw);
1050
+
1051
+ const timer = setTimeout(() => {
1052
+ responseEmitter.removeListener('raw', handleRaw);
1053
+ if (retryCount > 0) {
1054
+ readPanelTime(connection, retryCount - 1).then(resolve).catch(reject);
1055
+ } else {
1056
+ reject(new Error("timed out reading the panel clock"));
1057
+ }
1058
+ }, 2000);
1059
+
1060
+ connection.write(frameCommand("T?"), function (err) {
1061
+ if (err) {
1062
+ settle(reject, err);
1063
+ }
1064
+ });
1065
+ });
1066
+ }
1067
+
1068
+ // Finds the seven byte reply in the bytes read back. The terminator on its own
1069
+ // is not enough to go on: a day of the 13th and a month of October are 0x0D and
1070
+ // 0x0A, so a reply can carry what looks like a terminator inside it. Every field
1071
+ // is range checked as well, which is also what keeps a zone or area message from
1072
+ // being mistaken for a time - those are text, and no printable character is a
1073
+ // month between 1 and 12.
1074
+ function findPanelTimeReply(buffer) {
1075
+ for (var i = 0; i + 7 <= buffer.length; i++) {
1076
+ if (buffer[i + 5] !== 0x0D || buffer[i + 6] !== 0x0A) {
1077
+ continue;
1078
+ }
1079
+
1080
+ const reply = {
1081
+ day: buffer[i],
1082
+ month: buffer[i + 1],
1083
+ year: buffer[i + 2],
1084
+ hours: buffer[i + 3],
1085
+ minutes: buffer[i + 4]
1086
+ };
1087
+
1088
+ if (isPlausiblePanelTime(reply)) {
1089
+ return reply;
1090
+ }
1091
+ }
1092
+ return null;
1093
+ }
1094
+
1095
+ function isPlausiblePanelTime(time) {
1096
+ return time.day >= 1 && time.day <= 31
1097
+ && time.month >= 1 && time.month <= 12
1098
+ && time.year <= 99
1099
+ && time.hours <= 23
1100
+ && time.minutes <= 59;
1101
+ }
1102
+
1103
+ // The panel carries a two digit year, so it is read against the century we are
1104
+ // in. A panel that has lost its clock altogether reads as wildly out and is set.
1105
+ function panelTimeToDate(time, now) {
1106
+ const century = Math.floor(now.getFullYear() / 100) * 100;
1107
+ return new Date(century + time.year, time.month - 1, time.day, time.hours, time.minutes, 0, 0);
1108
+ }
1109
+
1110
+ function localPanelTime(now) {
1111
+ return {
1112
+ day: now.getDate(),
1113
+ month: now.getMonth() + 1,
1114
+ year: now.getFullYear() % 100,
1115
+ hours: now.getHours(),
1116
+ minutes: now.getMinutes()
1117
+ };
1118
+ }
1119
+
1120
+ // How far the panel is ahead of us, in whole minutes. Negative means behind.
1121
+ function clockDriftMinutes(time, now) {
1122
+ const panel = panelTimeToDate(time, now);
1123
+ const local = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
1124
+ return Math.round((panel.getTime() - local.getTime()) / 60000);
1125
+ }
1126
+
1127
+ function formatPanelTime(time) {
1128
+ return `${zpad(time.day, 2)}/${zpad(time.month, 2)}/${zpad(time.year, 2)} ${zpad(time.hours, 2)}:${zpad(time.minutes, 2)}`;
1129
+ }
1130
+
1131
+ function describeDrift(minutes) {
1132
+ const ahead = minutes > 0;
1133
+ const size = Math.abs(minutes);
1134
+ const amount = size < 120 ? `${size} minute${size === 1 ? "" : "s"}`
1135
+ : size < 2880 ? `${Math.round(size / 60)} hours`
1136
+ : `${Math.round(size / 1440)} days`;
1137
+ return `${amount} ${ahead ? "ahead" : "behind"}`;
1138
+ }
1139
+
1140
+ // Day, month, two digit year, hours and minutes as raw bytes, all of which are
1141
+ // below 0x80 so they survive being written out.
1142
+ function panelTimeCommand(now) {
1143
+ const time = localPanelTime(now);
1144
+ return Buffer.from([0x54, time.day, time.month, time.year, time.hours, time.minutes]);
1145
+ }
1146
+
1147
+ // 0x2F is the / that closes a command and 0x5C the \ that opens one, so a time
1148
+ // carrying either puts a framing character inside the command. A panel that
1149
+ // counts the five bytes it is expecting reads that correctly, one that scans for
1150
+ // the / instead would cut the command short, and there is no way to tell which
1151
+ // from here. Of the five, only the minutes can land on one - 47 minutes past -
1152
+ // and a minute's wait clears it. The year can too, in 2047 and 2092, and waiting
1153
+ // cannot help with that, so the command goes as it is.
1154
+ function shouldDeferClockSet(now) {
1155
+ return localPanelTime(now).minutes === 0x2F;
1156
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "displayName": "Homebridge Texecom",
3
3
  "name": "homebridge-texecom-full",
4
4
  "description": "A plugin for homebridge to integrate Texecom Premier Elite alarm systems into HomeKit",
5
- "version": "4.3.1-beta.3",
5
+ "version": "4.4.0",
6
6
  "author": {
7
7
  "name": "Chris Posthumus",
8
8
  "email": "chris.posthumus@gmail.com"