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.
- package/CHANGELOG.md +7 -0
- package/README.md +16 -11
- package/SECURITY.md +2 -2
- package/config.schema.json +9 -2
- package/dist/accessories/base.d.ts +19 -0
- package/dist/accessories/base.js +41 -0
- package/dist/accessories/global-eco.d.ts +4 -8
- package/dist/accessories/global-eco.js +68 -79
- package/dist/accessories/protect.d.ts +7 -6
- package/dist/accessories/protect.js +84 -117
- package/dist/accessories/temperature-sensor.js +6 -0
- package/dist/accessories/thermostat.d.ts +1 -0
- package/dist/accessories/thermostat.js +111 -16
- package/dist/api/alarm-feed.d.ts +60 -0
- package/dist/api/alarm-feed.js +77 -0
- package/dist/api/observe.js +12 -1
- package/dist/api/protobuf.d.ts +1 -1
- package/dist/api/rest.js +3 -1
- package/dist/api/session.d.ts +2 -1
- package/dist/api/session.js +4 -3
- package/dist/api/thermostat-write.d.ts +10 -0
- package/dist/api/thermostat-write.js +53 -5
- package/dist/api/transport.d.ts +27 -10
- package/dist/api/transport.js +270 -190
- package/dist/diagnostics/collector.d.ts +1 -1
- package/dist/diagnostics/collector.js +2 -1
- package/dist/diagnostics/format.js +1 -1
- package/dist/errors/index.d.ts +19 -5
- package/dist/errors/index.js +26 -6
- package/dist/nest-signals.d.ts +69 -0
- package/dist/nest-signals.js +161 -0
- package/dist/pagerduty-signals.d.ts +34 -0
- package/dist/pagerduty-signals.js +101 -0
- package/dist/platform.d.ts +2 -2
- package/dist/platform.js +254 -65
- package/dist/settings.d.ts +30 -19
- package/dist/settings.js +33 -22
- package/dist/state/classify.d.ts +14 -3
- package/dist/state/classify.js +5 -3
- package/dist/state/global-eco-state.d.ts +64 -0
- package/dist/state/global-eco-state.js +79 -0
- package/dist/state/protect-state.d.ts +11 -3
- package/dist/state/protect-state.js +31 -22
- package/dist/state/registry.d.ts +6 -0
- package/dist/state/registry.js +23 -10
- package/dist/state/thermostat-state.d.ts +9 -0
- package/dist/state/thermostat-state.js +23 -0
- package/dist/types/config.d.ts +3 -0
- package/dist/types/device.d.ts +13 -4
- package/dist/types/nest.d.ts +5 -1
- package/dist/utils/bound-characteristics.d.ts +11 -0
- package/dist/utils/bound-characteristics.js +60 -18
- package/dist/utils/retry.d.ts +3 -2
- package/dist/utils/retry.js +6 -7
- package/dist/utils/validators.js +1 -0
- package/docs/PROTOCOL.md +10 -7
- package/docs/README-DETAILED.md +43 -15
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
111
|
-
5. **No occupancy:** battery Protect,
|
|
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**
|
|
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
|
-
|
|
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
|
|
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.
|
package/config.schema.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"title": "Expose Protect occupancy",
|
|
40
40
|
"type": "boolean",
|
|
41
41
|
"default": true,
|
|
42
|
-
"description": "Publish an occupancy sensor
|
|
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.
|
|
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
|
*
|
package/dist/accessories/base.js
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
41
|
-
*
|
|
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
|
-
|
|
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.#
|
|
60
|
+
this.#clearTimer();
|
|
86
61
|
}
|
|
87
|
-
#
|
|
88
|
-
this.#
|
|
89
|
-
this.#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.#
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
14
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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';
|