homebridge-mynest 2.0.2 → 2.1.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +16 -11
  3. package/SECURITY.md +2 -2
  4. package/config.schema.json +9 -2
  5. package/dist/accessories/base.d.ts +19 -0
  6. package/dist/accessories/base.js +41 -0
  7. package/dist/accessories/global-eco.d.ts +4 -8
  8. package/dist/accessories/global-eco.js +68 -79
  9. package/dist/accessories/protect.d.ts +7 -6
  10. package/dist/accessories/protect.js +84 -117
  11. package/dist/accessories/temperature-sensor.js +6 -0
  12. package/dist/accessories/thermostat.d.ts +1 -0
  13. package/dist/accessories/thermostat.js +111 -16
  14. package/dist/api/alarm-feed.d.ts +60 -0
  15. package/dist/api/alarm-feed.js +77 -0
  16. package/dist/api/observe.js +12 -1
  17. package/dist/api/protobuf.d.ts +1 -1
  18. package/dist/api/rest.js +3 -1
  19. package/dist/api/session.d.ts +2 -1
  20. package/dist/api/session.js +4 -3
  21. package/dist/api/thermostat-write.d.ts +10 -0
  22. package/dist/api/thermostat-write.js +53 -5
  23. package/dist/api/transport.d.ts +27 -10
  24. package/dist/api/transport.js +270 -190
  25. package/dist/diagnostics/collector.d.ts +1 -1
  26. package/dist/diagnostics/collector.js +2 -1
  27. package/dist/diagnostics/format.js +1 -1
  28. package/dist/errors/index.d.ts +19 -5
  29. package/dist/errors/index.js +26 -6
  30. package/dist/nest-signals.d.ts +69 -0
  31. package/dist/nest-signals.js +161 -0
  32. package/dist/pagerduty-signals.d.ts +34 -0
  33. package/dist/pagerduty-signals.js +101 -0
  34. package/dist/platform.d.ts +2 -2
  35. package/dist/platform.js +254 -65
  36. package/dist/settings.d.ts +30 -19
  37. package/dist/settings.js +33 -22
  38. package/dist/state/classify.d.ts +14 -3
  39. package/dist/state/classify.js +5 -3
  40. package/dist/state/global-eco-state.d.ts +64 -0
  41. package/dist/state/global-eco-state.js +79 -0
  42. package/dist/state/protect-state.d.ts +11 -3
  43. package/dist/state/protect-state.js +31 -22
  44. package/dist/state/registry.d.ts +6 -0
  45. package/dist/state/registry.js +23 -10
  46. package/dist/state/thermostat-state.d.ts +9 -0
  47. package/dist/state/thermostat-state.js +23 -0
  48. package/dist/types/config.d.ts +3 -0
  49. package/dist/types/device.d.ts +13 -4
  50. package/dist/types/nest.d.ts +5 -1
  51. package/dist/utils/bound-characteristics.d.ts +11 -0
  52. package/dist/utils/bound-characteristics.js +60 -18
  53. package/dist/utils/retry.d.ts +3 -2
  54. package/dist/utils/retry.js +6 -7
  55. package/dist/utils/validators.js +1 -0
  56. package/docs/PROTOCOL.md +10 -7
  57. package/docs/README-DETAILED.md +43 -15
  58. package/package.json +11 -10
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project are documented here. From the first release onward, [release-please](https://github.com/googleapis/release-please) owns this file — do not hand-edit version headings after `0.1.0` ships.
4
4
 
5
+ ## [2.1.0](https://github.com/tbaur/homebridge-mynest/compare/v2.0.2...v2.1.0) (2026-10-26)
6
+
7
+
8
+ ### Features
9
+
10
+ * page through homebridge-pagerduty for smoke, CO and a stopped plugin ([#67](https://github.com/tbaur/homebridge-mynest/issues/67)) ([95d7afb](https://github.com/tbaur/homebridge-mynest/commit/95d7afbc719a68be874382ca7476735c398726f6))
11
+
5
12
  ## [2.0.2](https://github.com/tbaur/homebridge-mynest/compare/v2.0.1...v2.0.2) (2026-10-05)
6
13
 
7
14
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ Expose Nest thermostats, Nest Protect smoke/CO alarms, and Nest Temperature Sens
15
15
  - **Thermostats:** temperature, mode, activity, setpoints, humidity when Nest reports it, plus an Eco Mode switch per thermostat
16
16
  - **Optional house-wide Eco switch:** `exposeGlobalEcoSwitch`
17
17
  - **Thermostat writes:** mode, setpoints, and Eco only when **Allow thermostat control** is on (off by default)
18
- - **Nest Protect:** smoke and CO from REST when available; battery and online from REST and/or Observe
18
+ - **Nest Protect:** smoke and CO sensors on every Protect. A missing REST reading is a fault, not a missing tile. Battery-low status can still come from Observe. The smoke and CO tiles stay inactive while that sensor has no REST number or the REST feed is stale, including when Observe says the Protect is online
19
19
  - **Optional Protect occupancy and temperature**
20
20
  - **Temperature Sensors:** Nest Temperature Sensor (kryptonite) pucks
21
21
  - **Dual transport:** Nest REST subscribe merged with HTTP/2 Observe, so Observe-only devices still appear
@@ -24,10 +24,10 @@ Expose Nest thermostats, Nest Protect smoke/CO alarms, and Nest Temperature Sens
24
24
  - **No accessory churn** on a cloud blip or expired token. Devices leave HomeKit only after Nest-confirmed drops
25
25
  - **Circuit breakers** on REST and Observe so a dead Nest edge fails fast
26
26
  - **Diagnostics** *(optional):* health heartbeats in the Homebridge log, with one warning when health becomes degraded
27
+ - **PagerDuty** *(optional, off by default):* page when a Protect detects smoke or CO, the Protect alarm feed has been stale for 5 minutes, or the plugin has stopped because Nest rejected the token. Needs Node 22.13 or later. On an older Node 22 the plugin still runs and skips the page. See [PagerDuty](docs/README-DETAILED.md#pagerduty)
27
28
 
28
29
  ### Quality
29
- <!-- Canonical test count lives here only; keep other docs number-free to avoid multi-place updates. -->
30
- - **576 tests** with a per-area coverage floor, and a CI step that fails if the suite leaks an open handle
30
+ - A per-area test coverage floor, and a CI step that fails if the suite leaks an open handle
31
31
  - **Built for Homebridge 2:** stored getters and `updateValue(...)`, not the removed `getValue()`
32
32
  - **Secret hygiene:** access tokens are redacted from logs
33
33
  - **No analytics**
@@ -68,7 +68,7 @@ Use the Homebridge UI, or add the platform to `config.json`:
68
68
 
69
69
  ### 4. Restart Homebridge
70
70
 
71
- Thermostats, Protects, and temperature sensors appear in the Home app as Nest reports them. The log should show `Connected to Nest (REST up; Observe connecting)`, then device adds, then `Platform ready`.
71
+ Thermostats, Protects, and temperature sensors appear in the Home app as Nest reports them. The log should show `Connected to Nest`, then one `Adding ...` line per device, then `Platform ready`.
72
72
 
73
73
  Thermostat control stays off until you enable **Allow thermostat control**. Optionally enable **Expose global Eco switch**.
74
74
 
@@ -77,8 +77,8 @@ Thermostat control stays off until you enable **Allow thermostat control**. Opti
77
77
  | Nest device | HomeKit accessory | Notes |
78
78
  | --- | --- | --- |
79
79
  | Thermostat | Thermostat + Eco Mode switch | Observe is source of truth. Writes need `allowThermostatControl`. |
80
- | Nest Protect | Smoke + CO (+ optional occupancy / temp) | Smoke/CO require REST `topaz`. Occupancy is ~10-minute presence, not motion. |
81
- | Temperature Sensor | Temperature Sensor | Battery + temperature |
80
+ | Nest Protect | Smoke + CO (+ optional occupancy / temp) | Smoke and CO stay published. A missing REST number faults that sensor. Occupancy is ~10-minute presence, not motion. |
81
+ | Temperature Sensor | Temperature Sensor | Battery + temperature. A missing temperature faults the sensor. |
82
82
 
83
83
  Cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account-only homes are out of scope.
84
84
 
@@ -92,12 +92,13 @@ Cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account
92
92
  | `accessToken` | — | Required. Nest Account `access_token`. See [docs/AUTH.md](docs/AUTH.md). |
93
93
  | `allowThermostatControl` | `false` | Send mode/setpoint/Eco changes to Nest. |
94
94
  | `exposeGlobalEcoSwitch` | `false` | One switch that sets Eco on every thermostat. Writes still need `allowThermostatControl`. |
95
- | `exposeProtectOccupancy` | `true` | Occupancy from REST `auto_away` on a mains-powered Protect. |
95
+ | `exposeProtectOccupancy` | `true` | Occupancy from REST `auto_away` when that field is a boolean and the Protect is not reported as battery-powered. Unknown power does not hide a boolean Nest already sent. |
96
96
  | `exposeProtectTemperature` | `false` | Temperature/humidity measured by each Protect. |
97
97
  | `ignoredDeviceIds` | `[]` | Device IDs or serials to leave out of HomeKit. |
98
98
  | `fieldTest` | `false` | Use Nest field-test hosts. |
99
99
  | `diagnosticsInterval` | `0` | Seconds between health heartbeats; `0` off, else `30`–`86400`. |
100
100
  | `structuredLogs` | `false` | With diagnostics, also emit a JSON line. |
101
+ | `pagerdutySignals` | `false` | Page through homebridge-pagerduty. Off until turned on. See [PagerDuty](docs/README-DETAILED.md#pagerduty). |
101
102
  | `debug` | `false` | Verbose logging; tokens are redacted. |
102
103
 
103
104
  The [detailed documentation](docs/README-DETAILED.md#full-configuration-reference) explains each option and what happens when REST or Observe is down.
@@ -107,8 +108,8 @@ The [detailed documentation](docs/README-DETAILED.md#full-configuration-referenc
107
108
  1. **Authentication error:** token missing, truncated, Google JWT/`ya29.`, or revoked. Capture a fresh Nest Account token ([docs/AUTH.md](docs/AUTH.md)).
108
109
  2. **Thermostat missing:** modern thermostats are Observe-only on some accounts. Enable `debug` for stream detail.
109
110
  3. **Eco / setpoint changes snap back:** enable **Allow thermostat control**.
110
- 4. **Protect without smoke/CO:** likely Observe-only. The accessory still appears; alarm tiles wait for REST.
111
- 5. **No occupancy:** battery Protect, power unknown, Observe-only, or `exposeProtectOccupancy` off. Occupancy is not motion.
111
+ 4. **Protect smoke/CO shows a fault:** REST has not supplied that sensor's number, or the REST feed is stale. The tile stays. A line `REST topaz auto_away=... smoke_status=... co_status=...` is the raw reading, including when the summary sentence did not change.
112
+ 5. **No occupancy, or a room that stays Occupied:** occupancy is Nest's `auto_away` inverted (false means Occupied), not motion. It is omitted for a battery Protect, when `auto_away` is missing, or when `exposeProtectOccupancy` is off. A stuck Occupied is that boolean staying false. The `REST topaz` line shows the raw value each time a Protect object arrives.
112
113
 
113
114
  The [full troubleshooting list](docs/README-DETAILED.md#troubleshooting) covers more cases, including a stuck Home app tile.
114
115
 
@@ -120,8 +121,8 @@ See [SECURITY.md](SECURITY.md) and [docs/AUTH.md](docs/AUTH.md).
120
121
 
121
122
  ## Requirements
122
123
 
123
- - **Homebridge 2.x** (Homebridge 1.x is not supported)
124
- - **Node.js 22, 24, or 26**
124
+ - **Homebridge 2.x**
125
+ - **Node.js 22, 24, or 26.** PagerDuty signals need 22.13 or later; the rest of the plugin does not
125
126
  - A Nest Account (not Google-only) with an `access_token` from [home.nest.com/session](https://home.nest.com/session)
126
127
 
127
128
  ## More Info
@@ -135,6 +136,10 @@ See [SECURITY.md](SECURITY.md) and [docs/AUTH.md](docs/AUTH.md).
135
136
  - [Changelog](CHANGELOG.md)
136
137
  - [Report issues](https://github.com/tbaur/homebridge-mynest/issues)
137
138
 
139
+ ## Trademarks
140
+
141
+ homebridge-mynest is an independent project. It is not affiliated with or endorsed by Google. Nest, Nest Protect, and Nest Temperature Sensor are trademarks of Google LLC.
142
+
138
143
  ## License
139
144
 
140
145
  Copyright 2026 tbaur
package/SECURITY.md CHANGED
@@ -9,7 +9,7 @@ Only the latest published release is supported. Fixes are shipped forward rather
9
9
  | Latest release | ✅ Active support |
10
10
  | Anything older | ❌ Unsupported — upgrade |
11
11
 
12
- The next release requires **Homebridge 2.x and Node.js 22 or newer**; Homebridge 1.x is no longer supported.
12
+ Requires **Homebridge 2.x** and **Node.js 22, 24, or 26**.
13
13
 
14
14
  ## Reporting a Vulnerability
15
15
 
@@ -51,4 +51,4 @@ This plugin implements:
51
51
 
52
52
  Out-of-scope devices are listed in the [README](README.md#supported-devices): cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account-only homes.
53
53
 
54
- Life-safety alarm state for Protects is only published while Nest REST can refresh it; Observe-only units and Protects whose REST feed is down do not keep a frozen all-clear.
54
+ Life-safety alarm state for Protects is only treated as live while Nest REST can refresh it. A missing number or a stale REST feed leaves the smoke and CO sensors published, inactive, and faulted, so HomeKit does not keep a frozen all-clear. An `app_launch` that is empty or under half the previous object count still merges the objects it contains, and it does not refresh that clock.
@@ -39,7 +39,7 @@
39
39
  "title": "Expose Protect occupancy",
40
40
  "type": "boolean",
41
41
  "default": true,
42
- "description": "Publish an occupancy sensor for mains-powered Protects that report Nest auto_away. This is ~10-minute presence, not motion."
42
+ "description": "Publish an occupancy sensor when Nest auto_away is a boolean and the Protect is not reported as battery-powered. Unknown power does not hide a boolean Nest already sent. This is ~10-minute presence, not motion."
43
43
  },
44
44
  "exposeProtectTemperature": {
45
45
  "title": "Expose Protect temperature",
@@ -55,7 +55,7 @@
55
55
  "items": {
56
56
  "type": "string",
57
57
  "title": "Device ID",
58
- "placeholder": "e.g. 18B4300000ACBFBD"
58
+ "placeholder": "e.g. 18B4300000000002"
59
59
  }
60
60
  },
61
61
  "fieldTest": {
@@ -81,6 +81,12 @@
81
81
  "default": false,
82
82
  "description": "When diagnostics are enabled, also emit a machine-readable JSON line alongside each human-readable health report."
83
83
  },
84
+ "pagerdutySignals": {
85
+ "title": "Send Signals to PagerDuty",
86
+ "type": "boolean",
87
+ "default": false,
88
+ "description": "Write this plugin's alert conditions to homebridge-pagerduty's signal store. Then allow homebridge-mynest to page in homebridge-pagerduty's settings. Needs Node 22.13 or later."
89
+ },
84
90
  "debug": {
85
91
  "title": "Debug logging",
86
92
  "type": "boolean",
@@ -125,6 +131,7 @@
125
131
  "type": "number"
126
132
  },
127
133
  "structuredLogs",
134
+ "pagerdutySignals",
128
135
  "debug"
129
136
  ]
130
137
  }
@@ -39,11 +39,30 @@ export declare abstract class NestAccessory<TState> {
39
39
  protected readonly binder: CharacteristicBinder;
40
40
  protected identity: DeviceIdentity;
41
41
  protected state: TState;
42
+ /**
43
+ * True after Nest dropped this device from its inventory while HomeKit
44
+ * still shows it (removal is waiting on its own guards). Readings are then
45
+ * last-known only, so subclasses mark them inactive or faulted.
46
+ */
47
+ protected isUnreported: boolean;
42
48
  constructor(platform: MyNestPlatform, accessory: PlatformAccessory, device: {
43
49
  identity: DeviceIdentity;
44
50
  state: TState;
45
51
  }, log: Logger);
46
52
  get deviceId(): string;
53
+ get kind(): DeviceKind;
54
+ /**
55
+ * Nest no longer reports this device, but HomeKit cannot drop it yet.
56
+ *
57
+ * Pushes the current readers again so subclasses that consult
58
+ * {@link isUnreported} stop presenting the last values as live.
59
+ */
60
+ markUnreported(): void;
61
+ /**
62
+ * Remove every service this accessory published, before another kind of
63
+ * accessory takes over the same HomeKit accessory.
64
+ */
65
+ removeAllServices(): void;
47
66
  /**
48
67
  * Take new state and push it to HomeKit.
49
68
  *
@@ -34,6 +34,12 @@ class NestAccessory {
34
34
  binder;
35
35
  identity;
36
36
  state;
37
+ /**
38
+ * True after Nest dropped this device from its inventory while HomeKit
39
+ * still shows it (removal is waiting on its own guards). Readings are then
40
+ * last-known only, so subclasses mark them inactive or faulted.
41
+ */
42
+ isUnreported = false;
37
43
  /** Last summary emitted, so an unchanged one stays at debug. */
38
44
  #lastSummary = null;
39
45
  constructor(platform, accessory, device, log) {
@@ -49,6 +55,37 @@ class NestAccessory {
49
55
  get deviceId() {
50
56
  return this.identity.id;
51
57
  }
58
+ get kind() {
59
+ return this.identity.kind;
60
+ }
61
+ /**
62
+ * Nest no longer reports this device, but HomeKit cannot drop it yet.
63
+ *
64
+ * Pushes the current readers again so subclasses that consult
65
+ * {@link isUnreported} stop presenting the last values as live.
66
+ */
67
+ markUnreported() {
68
+ if (this.isUnreported) {
69
+ return;
70
+ }
71
+ this.isUnreported = true;
72
+ this.log.warn(`${this.identity.name}: no longer reported by Nest, marked inactive`);
73
+ this.binder.refresh();
74
+ }
75
+ /**
76
+ * Remove every service this accessory published, before another kind of
77
+ * accessory takes over the same HomeKit accessory.
78
+ */
79
+ removeAllServices() {
80
+ const { Service: HapService } = this.platform;
81
+ for (const service of [...this.accessory.services]) {
82
+ if (service.UUID === HapService.AccessoryInformation.UUID) {
83
+ continue;
84
+ }
85
+ this.binder.unbindService(service);
86
+ this.accessory.removeService(service);
87
+ }
88
+ }
52
89
  /**
53
90
  * Take new state and push it to HomeKit.
54
91
  *
@@ -60,6 +97,10 @@ class NestAccessory {
60
97
  const previousIdentity = this.identity;
61
98
  this.identity = device.identity;
62
99
  this.state = device.state;
100
+ if (this.isUnreported) {
101
+ this.isUnreported = false;
102
+ this.log.info(`${this.identity.name}: reported by Nest again`);
103
+ }
63
104
  // Only when it actually changed. These four values essentially never do, and
64
105
  // re-applying them ran HAP's full value-validation path four times per
65
106
  // device per refresh on the hot update path.
@@ -7,7 +7,9 @@
7
7
  * @fileoverview House-wide Eco Mode switch (all Nest thermostats).
8
8
  *
9
9
  * HomeKit has no Eco thermostat mode, so Eco is a Switch. This accessory turns
10
- * Eco on or off for every published thermostat at once.
10
+ * Eco on or off for every published thermostat at once. What the switch shows
11
+ * is decided by `state/global-eco-state.ts`; this class owns HomeKit, the
12
+ * expiry timer, and the log lines.
11
13
  */
12
14
  import type { PlatformAccessory } from 'homebridge';
13
15
  import type { Logger } from '../utils/logger';
@@ -16,13 +18,7 @@ import type { MyNestPlatform } from '../platform';
16
18
  export declare class GlobalEcoAccessory {
17
19
  #private;
18
20
  constructor(platform: MyNestPlatform, accessory: PlatformAccessory, log: Logger);
19
- /**
20
- * Reflect whether every Nest thermostat is currently in Eco.
21
- *
22
- * While a HomeKit write is pending confirmation, ignore Nest aggregates that
23
- * do not yet match the desired value (partial Eco reports). If Nest never
24
- * matches within {@link PENDING_ECO_MAX_MS}, take Nest truth.
25
- */
21
+ /** Reflect whether every Nest thermostat is currently in Eco. */
26
22
  updateAllEco(allEco: boolean): void;
27
23
  /**
28
24
  * Release the pending-write backstop.
@@ -8,38 +8,28 @@
8
8
  * @fileoverview House-wide Eco Mode switch (all Nest thermostats).
9
9
  *
10
10
  * HomeKit has no Eco thermostat mode, so Eco is a Switch. This accessory turns
11
- * Eco on or off for every published thermostat at once.
11
+ * Eco on or off for every published thermostat at once. What the switch shows
12
+ * is decided by `state/global-eco-state.ts`; this class owns HomeKit, the
13
+ * expiry timer, and the log lines.
12
14
  */
13
15
  Object.defineProperty(exports, "__esModule", { value: true });
14
16
  exports.GlobalEcoAccessory = void 0;
15
17
  const settings_1 = require("../settings");
18
+ const global_eco_state_1 = require("../state/global-eco-state");
16
19
  const bound_characteristics_1 = require("../utils/bound-characteristics");
17
20
  const sanitizers_1 = require("../utils/sanitizers");
18
- /**
19
- * How long a successful HomeKit Eco write may ignore Nest aggregates that have
20
- * not yet matched. After this, Nest truth wins (Nest app / failed Observe).
21
- */
22
- const PENDING_ECO_MAX_MS = 45_000;
23
21
  /** Optional Switch that drives Eco on every thermostat. */
24
22
  class GlobalEcoAccessory {
25
23
  #platform;
26
24
  #accessory;
27
25
  #log;
28
26
  #binder;
29
- #allEco = false;
30
- /**
31
- * Desired aggregate after a successful HomeKit write. While set, Nest sync
32
- * must not push a partial aggregate (staggered Eco confirms) or unrelated
33
- * inventory traffic will snap the switch back.
34
- */
35
- #pendingAllEco = null;
36
- #pendingSinceMs = null;
27
+ #state;
37
28
  /**
38
29
  * Backstop for {@link PENDING_ECO_MAX_MS}.
39
30
  *
40
- * The age check in {@link updateAllEco} only runs when Nest sends another
41
- * update. If Observe dies right after a successful write, nothing would ever
42
- * clear the optimistic value and the switch would hold it indefinitely.
31
+ * The age check on a Nest report only runs when Nest sends another one. If
32
+ * Observe goes quiet right after a write, nothing else would expire it.
43
33
  */
44
34
  #pendingTimer = null;
45
35
  constructor(platform, accessory, log) {
@@ -48,30 +38,15 @@ class GlobalEcoAccessory {
48
38
  this.#log = log;
49
39
  this.#binder = new bound_characteristics_1.CharacteristicBinder(log, settings_1.GLOBAL_ECO_DISPLAY_NAME);
50
40
  this.#applyAccessoryInformation();
41
+ const cachedOn = this.#accessory.getService(this.#platform.Service.Switch)
42
+ ?.getCharacteristic(this.#platform.Characteristic.On).value;
43
+ this.#state = (0, global_eco_state_1.initialGlobalEcoState)(cachedOn === true);
51
44
  this.#bindSwitch();
52
45
  this.#binder.refresh();
53
46
  }
54
- /**
55
- * Reflect whether every Nest thermostat is currently in Eco.
56
- *
57
- * While a HomeKit write is pending confirmation, ignore Nest aggregates that
58
- * do not yet match the desired value (partial Eco reports). If Nest never
59
- * matches within {@link PENDING_ECO_MAX_MS}, take Nest truth.
60
- */
47
+ /** Reflect whether every Nest thermostat is currently in Eco. */
61
48
  updateAllEco(allEco) {
62
- if (this.#pendingAllEco !== null) {
63
- if (allEco === this.#pendingAllEco) {
64
- this.#clearPending();
65
- }
66
- else if (this.#pendingSinceMs !== null
67
- && Date.now() - this.#pendingSinceMs >= PENDING_ECO_MAX_MS) {
68
- this.#expirePending();
69
- }
70
- else {
71
- return;
72
- }
73
- }
74
- this.#allEco = allEco;
49
+ this.#dispatch({ type: 'nest_report', allEco, nowMs: Date.now() });
75
50
  this.#binder.refresh();
76
51
  }
77
52
  /**
@@ -82,37 +57,53 @@ class GlobalEcoAccessory {
82
57
  * still fire up to 45s later and refresh a torn-down accessory.
83
58
  */
84
59
  dispose() {
85
- this.#clearPending();
60
+ this.#clearTimer();
86
61
  }
87
- #clearPending() {
88
- this.#pendingAllEco = null;
89
- this.#pendingSinceMs = null;
90
- if (this.#pendingTimer) {
91
- clearTimeout(this.#pendingTimer);
92
- this.#pendingTimer = null;
62
+ #dispatch(event) {
63
+ const { state, outcome } = (0, global_eco_state_1.reduceGlobalEco)(this.#state, event);
64
+ this.#state = state;
65
+ this.#logOutcome(outcome);
66
+ if (state.pending === null) {
67
+ this.#clearTimer();
68
+ }
69
+ else if (event.type === 'write_sent') {
70
+ this.#armTimer();
93
71
  }
94
72
  }
95
- /** Give up on an unconfirmed write and let Nest's own value stand. */
96
- #expirePending() {
97
- this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Nest did not confirm Eco change within ${PENDING_ECO_MAX_MS / 1000}s — following Nest`);
98
- this.#clearPending();
73
+ #logOutcome(outcome) {
74
+ const prefix = `${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Eco change not confirmed in ${global_eco_state_1.PENDING_ECO_MAX_MS / 1000}s`;
75
+ if (outcome === 'expired_to_nest') {
76
+ this.#log.warn(`${prefix}, showing Nest state`);
77
+ }
78
+ else if (outcome === 'expired_without_report') {
79
+ this.#log.warn(prefix);
80
+ }
99
81
  }
100
- /** Start the backstop that gives Nest the last word even if it goes quiet. */
101
- #armPending(ecoOn) {
102
- this.#clearPending();
103
- this.#pendingAllEco = ecoOn;
104
- this.#pendingSinceMs = Date.now();
82
+ #armTimer() {
83
+ this.#clearTimer();
105
84
  this.#pendingTimer = setTimeout(() => {
106
85
  this.#pendingTimer = null;
107
- if (this.#pendingAllEco === null) {
108
- return;
109
- }
110
- this.#expirePending();
86
+ this.#dispatch({ type: 'expire' });
111
87
  this.#binder.refresh();
112
- }, PENDING_ECO_MAX_MS);
88
+ }, global_eco_state_1.PENDING_ECO_MAX_MS);
113
89
  // Homebridge owns the process lifetime; this must not hold Node open.
114
90
  this.#pendingTimer.unref?.();
115
91
  }
92
+ #clearTimer() {
93
+ if (this.#pendingTimer) {
94
+ clearTimeout(this.#pendingTimer);
95
+ this.#pendingTimer = null;
96
+ }
97
+ }
98
+ /**
99
+ * Push the switch after `onSet` returns.
100
+ *
101
+ * HAP writes the requested On value after the setter resolves, so a refresh
102
+ * inside the setter is overwritten.
103
+ */
104
+ #refreshSwitch = () => {
105
+ this.#binder.refresh();
106
+ };
116
107
  #bindSwitch() {
117
108
  const { Characteristic, Service: HapService } = this.#platform;
118
109
  const service = this.#accessory.getService(HapService.Switch)
@@ -120,41 +111,39 @@ class GlobalEcoAccessory {
120
111
  service.setCharacteristic(Characteristic.Name, settings_1.GLOBAL_ECO_DISPLAY_NAME);
121
112
  this.#accessory
122
113
  .setPrimaryService?.(service);
123
- this.#binder.bind(service, Characteristic.On, () => this.#allEco, {
114
+ this.#binder.bind(service, Characteristic.On, () => this.#state.shown, {
124
115
  write: async (value) => {
125
116
  await this.#setEco(value === true || value === 1);
126
117
  },
127
118
  });
128
119
  }
129
120
  async #setEco(ecoOn) {
121
+ const startedAtMs = Date.now();
122
+ let sent;
130
123
  try {
131
- const sent = await this.#platform.applyGlobalEcoWrite(ecoOn);
132
- if (!sent) {
133
- this.#clearPending();
134
- if (!this.#platform.resolvedConfig.allowThermostatControl) {
135
- this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: ignoring HomeKit change — enable Allow thermostat control in config.`);
136
- }
137
- // HAP assigns On after onSet; revert to Nest-derived `#allEco`.
138
- setImmediate(() => this.#binder.refresh());
139
- return;
140
- }
141
- this.#log.info(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: ${ecoOn ? 'Updating all thermostats to Eco' : 'Clearing Eco on all thermostats'}`);
142
- // Optimistic until Nest's all-Eco aggregate matches; do not refresh here
143
- // or HAP's post-onSet value is overwritten by a stale read.
144
- this.#armPending(ecoOn);
145
- this.#allEco = ecoOn;
124
+ sent = await this.#platform.applyGlobalEcoWrite(ecoOn);
146
125
  }
147
126
  catch (error) {
148
- this.#clearPending();
149
- this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Eco update failed: ${(0, sanitizers_1.sanitizeError)(error)}`);
150
- setImmediate(() => this.#binder.refresh());
151
- // Same distinction the thermostat draws: a refusal above resolves, but a
152
- // write that reached Nest and failed is reported so the user is not left
127
+ // Same distinction the thermostat draws: a refusal resolves, but a write
128
+ // that reached Nest and failed is reported so the user is not left
153
129
  // watching a switch flip back with no stated reason. The deferred refresh
154
130
  // clears HAP's error status a tick later, so it cannot stick.
131
+ this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Eco update failed: ${(0, sanitizers_1.sanitizeError)(error)}`);
132
+ setImmediate(this.#refreshSwitch);
155
133
  const { HAPStatus, HapStatusError } = this.#platform.api.hap;
156
134
  throw new HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
157
135
  }
136
+ if (!sent) {
137
+ if (!this.#platform.resolvedConfig.allowThermostatControl) {
138
+ this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: thermostat control is off, change ignored`);
139
+ }
140
+ setImmediate(this.#refreshSwitch);
141
+ return;
142
+ }
143
+ this.#log.info(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Eco ${ecoOn ? 'on' : 'off'} for all thermostats`);
144
+ // Optimistic until Nest's aggregate matches. No refresh here: HAP is about
145
+ // to write the same value.
146
+ this.#dispatch({ type: 'write_sent', ecoOn, startedAtMs, nowMs: Date.now() });
158
147
  }
159
148
  #applyAccessoryInformation() {
160
149
  const { Characteristic, Service: HapService } = this.#platform;
@@ -10,8 +10,8 @@
10
10
  * a PIR sensor, but neither Nest API publishes motion events: a 12.5-hour
11
11
  * capture of both transports on an occupied house recorded zero motion events
12
12
  * and zero occupancy changes. What is available is `auto_away`, Nest's own
13
- * verdict that nobody has been seen for roughly ten minutes, and only on
14
- * mains-powered units.
13
+ * verdict that nobody has been seen for roughly ten minutes, and omitted for
14
+ * a battery-powered Protect. Unknown power does not hide a boolean `auto_away`.
15
15
  *
16
16
  * So this accessory publishes occupancy only where that verdict exists, and
17
17
  * says why in the log when it does not. Presenting a ten-minute presence
@@ -19,10 +19,11 @@
19
19
  * automation built on it wrong.
20
20
  *
21
21
  * Smoke and CO come from REST `topaz` only. Observe streams safety traits, but
22
- * no public schema maps them and every captured sample reads all-clear, so an
23
- * Observe-only Protect deliberately gets no smoke/CO tiles. When REST later
24
- * goes stale, tiles stay published (so HomeKit rooms/automations survive) but
25
- * are marked inactive/faulted — never a live frozen all-clear. See `docs/PROTOCOL.md`.
22
+ * no public schema maps them and every captured sample reads all-clear, so
23
+ * those fields are not read. Every Protect still publishes both sensors. A
24
+ * missing number faults the sensor and does not write a clear. When REST later
25
+ * goes stale, tiles stay published (so HomeKit rooms and automations survive)
26
+ * but are marked inactive and faulted. See `docs/PROTOCOL.md`.
26
27
  */
27
28
  import type { PlatformAccessory } from 'homebridge';
28
29
  import type { NestDevice, ProtectState } from '../types/device';