homebridge-mynest 2.0.0 → 2.0.2

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,20 @@
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.0.2](https://github.com/tbaur/homebridge-mynest/compare/v2.0.1...v2.0.2) (2026-10-05)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * log one diagnostics health warning ([ede8013](https://github.com/tbaur/homebridge-mynest/commit/ede8013cccdf0cf31396755e03a04a2ce7b96966))
11
+
12
+ ## [2.0.1](https://github.com/tbaur/homebridge-mynest/compare/v2.0.0...v2.0.1) (2026-09-05)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * clamp published thermostat setpoints and report failed writes ([#46](https://github.com/tbaur/homebridge-mynest/issues/46)) ([c5bf879](https://github.com/tbaur/homebridge-mynest/commit/c5bf87993934dd17d43653bceb296c674a3fd7cc))
18
+
5
19
  ## [2.0.0](https://github.com/tbaur/homebridge-mynest/compare/v1.1.0...v2.0.0) (2026-08-05)
6
20
 
7
21
 
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Tests](https://github.com/tbaur/homebridge-mynest/actions/workflows/test.yml/badge.svg)](https://github.com/tbaur/homebridge-mynest/actions/workflows/test.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/homebridge-mynest?style=flat-square)](https://www.npmjs.com/package/homebridge-mynest)
5
5
  [![npm downloads](https://img.shields.io/npm/dt/homebridge-mynest?style=flat-square)](https://www.npmjs.com/package/homebridge-mynest)
6
- [![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024-green)](https://nodejs.org)
6
+ [![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green)](https://nodejs.org)
7
7
  [![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple)](https://homebridge.io)
8
8
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
9
9
 
@@ -12,32 +12,27 @@ Expose Nest thermostats, Nest Protect smoke/CO alarms, and Nest Temperature Sens
12
12
  ## Features
13
13
 
14
14
  ### Device Support
15
-
16
- - **Thermostats** — Current temperature, mode, activity, setpoints, humidity when Nest reports it; each thermostat includes an Eco Mode switch; optional house-wide Nest Eco Mode switch (`exposeGlobalEcoSwitch`); HomeKit can change mode/setpoints/Eco when Allow thermostat control is enabled (opt-in)
17
- - **Nest Protect** — Smoke and CO from REST when available; battery / online from REST and/or Observe; optional occupancy and Protect temperature
18
- - **Temperature Sensors** — Nest Temperature Sensor (kryptonite) pucks
19
- - **Dual transport** — Merges Nest REST subscribe with HTTP/2 Observe so Observe-only devices still appear
20
-
21
- ### Honesty about Nest's APIs
22
-
23
- - **Occupancy is not motion** — Nest publishes `auto_away` for mains-powered Protects: roughly a ten-minute presence verdict. Battery Protects, power-unknown Protects, and Observe-only Protects get no occupancy sensor rather than a stuck "empty house"; when REST later goes stale, a last occupancy reading stays published but is marked inactive/faulted
24
- - **Smoke/CO honesty without accessory churn** — Observe-only Protects omit smoke/CO until REST reports alarm state; if REST later goes down, tiles stay in HomeKit (rooms/automations keep their targets) but are marked inactive/faulted rather than freezing a live all-clear. Battery and online may remain from Observe
25
- - **Thermostats are Observe-first** — Modern Nest thermostats may be missing from REST entirely; this plugin does not rely on REST-only discovery for HVAC
15
+ - **Thermostats:** temperature, mode, activity, setpoints, humidity when Nest reports it, plus an Eco Mode switch per thermostat
16
+ - **Optional house-wide Eco switch:** `exposeGlobalEcoSwitch`
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
19
+ - **Optional Protect occupancy and temperature**
20
+ - **Temperature Sensors:** Nest Temperature Sensor (kryptonite) pucks
21
+ - **Dual transport:** Nest REST subscribe merged with HTTP/2 Observe, so Observe-only devices still appear
26
22
 
27
23
  ### Reliability
28
-
29
- - **No accessory churn on outages or token expiry** — Devices leave HomeKit only after Nest-confirmed drops (two-strike + truncation guards on Observe and REST). A cloud blip, REST-only boot, or expired access token stops updates but does not unregister thermostats, Protects, or temp sensors (paste a fresh token and restart)
30
- - **Circuit breakers** — Independent REST and Observe breakers fail fast when Nest's edge is returning sustained 5xx / network failures, then probe again after a short cooldown (auth and 403 paths keep their own handling)
31
- - **Diagnostics** *(optional)* — Opt-in health/activity heartbeats, boot/shutdown snapshots, and healthy/degraded transitions in the Homebridge log (REST + Observe transport gauges, breaker state, device inventory, API latency)
24
+ - **No accessory churn** on a cloud blip or expired token. Devices leave HomeKit only after Nest-confirmed drops
25
+ - **Circuit breakers** on REST and Observe so a dead Nest edge fails fast
26
+ - **Diagnostics** *(optional):* health heartbeats in the Homebridge log, with one warning when health becomes degraded
32
27
 
33
28
  ### Quality
34
-
35
29
  <!-- Canonical test count lives here only; keep other docs number-free to avoid multi-place updates. -->
36
- - **Built for Homebridge 2** — Live updates use stored getters + `updateValue(...)`, not the removed `getValue()` or stale `.value` reads
37
- - **576 tests** — Jest suite with a per-area coverage floor on every source directory (statements, branches, functions, and lines), and a CI step that fails if the suite leaks an open handle
38
- - **Strict TypeScript** — `strict` mode with unused locals/params and no implicit returns, plus type-aware linting (no floating promises)
39
- - **Secret hygiene** — Access tokens are redacted from logs, and untrusted Nest responses cannot reach `Object.prototype`
40
- - **No analytics** — Zero tracking or data collection
30
+ - **576 tests** with a per-area coverage floor, and a CI step that fails if the suite leaks an open handle
31
+ - **Built for Homebridge 2:** stored getters and `updateValue(...)`, not the removed `getValue()`
32
+ - **Secret hygiene:** access tokens are redacted from logs
33
+ - **No analytics**
34
+
35
+ Every option, occupancy caveat, and troubleshooting step is in [Detailed documentation](docs/README-DETAILED.md).
41
36
 
42
37
  ## Quick Start
43
38
 
@@ -45,17 +40,15 @@ Expose Nest thermostats, Nest Protect smoke/CO alarms, and Nest Temperature Sens
45
40
 
46
41
  **Homebridge UI** (recommended): Plugins → Search `mynest` → Install
47
42
 
48
- **Command line:**
49
-
50
43
  ```bash
51
44
  npm install -g homebridge-mynest
52
45
  ```
53
46
 
54
47
  ### 2. Get a Nest Account token
55
48
 
56
- You need a Nest Account (not Google-only) access token from [home.nest.com/session](https://home.nest.com/session). Steps and threat model: [docs/AUTH.md](https://github.com/tbaur/homebridge-mynest/blob/main/docs/AUTH.md).
49
+ You need a Nest Account (not Google-only) access token from [home.nest.com/session](https://home.nest.com/session). Steps and threat model: [docs/AUTH.md](docs/AUTH.md).
57
50
 
58
- Prefer treating that token like a password; Nest Account sessions are account-scoped credentials.
51
+ Treat that token like a password. Nest Account sessions are account-scoped credentials.
59
52
 
60
53
  ### 3. Configure
61
54
 
@@ -77,14 +70,14 @@ Use the Homebridge UI, or add the platform to `config.json`:
77
70
 
78
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`.
79
72
 
80
- Thermostat control (mode, setpoints, Eco) stays off until you enable **Allow thermostat control**. Optionally enable **Expose global Eco switch** for a house-wide Nest Eco Mode tile.
73
+ Thermostat control stays off until you enable **Allow thermostat control**. Optionally enable **Expose global Eco switch**.
81
74
 
82
75
  ## Supported Devices
83
76
 
84
77
  | Nest device | HomeKit accessory | Notes |
85
78
  | --- | --- | --- |
86
- | Thermostat | Thermostat + Eco Mode switch | Observe is source of truth; mode/setpoints/Eco writable when `allowThermostatControl` is on (opt-in). Optional house-wide Eco switch via `exposeGlobalEcoSwitch`. |
87
- | Nest Protect | Smoke + CO (+ optional occupancy / temp) | Smoke/CO require REST `topaz`; occupancy is ~10-minute presence |
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. |
88
81
  | Temperature Sensor | Temperature Sensor | Battery + temperature |
89
82
 
90
83
  Cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account-only homes are out of scope.
@@ -95,54 +88,51 @@ Cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account
95
88
 
96
89
  | Option | Default | Description |
97
90
  | --- | --- | --- |
98
- | `name` | `MyNest` | Required. Plugin instance name shown in Homebridge logs. |
99
- | `accessToken` | — | Required. Nest Account `access_token` from [docs/AUTH.md](https://github.com/tbaur/homebridge-mynest/blob/main/docs/AUTH.md). |
100
- | `allowThermostatControl` | `false` | Opt in to send mode/setpoint/Eco changes to Nest via BatchUpdateState. |
101
- | `exposeGlobalEcoSwitch` | `false` | Publish a Nest Eco Mode switch that sets Eco on every thermostat. Writes require `allowThermostatControl`. |
102
- | `exposeProtectOccupancy` | `true` | Occupancy from REST `auto_away` when Nest computes it on a mains-powered Protect. |
91
+ | `name` | `MyNest` | Required. Plugin instance name in Homebridge logs. |
92
+ | `accessToken` | — | Required. Nest Account `access_token`. See [docs/AUTH.md](docs/AUTH.md). |
93
+ | `allowThermostatControl` | `false` | Send mode/setpoint/Eco changes to Nest. |
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. |
103
96
  | `exposeProtectTemperature` | `false` | Temperature/humidity measured by each Protect. |
104
97
  | `ignoredDeviceIds` | `[]` | Device IDs or serials to leave out of HomeKit. |
105
98
  | `fieldTest` | `false` | Use Nest field-test hosts. |
106
- | `diagnosticsInterval` | `0` | Seconds between health heartbeats in the log; `0` off, else `30`–`86400` (24h). |
107
- | `structuredLogs` | `false` | When diagnostics are enabled, also emit a machine-readable JSON line alongside each human summary. |
99
+ | `diagnosticsInterval` | `0` | Seconds between health heartbeats; `0` off, else `30`–`86400`. |
100
+ | `structuredLogs` | `false` | With diagnostics, also emit a JSON line. |
108
101
  | `debug` | `false` | Verbose logging; tokens are redacted. |
109
102
 
110
- ## Protect occupancy
111
-
112
- Nest does not expose a reliable Protect motion event stream to third-party clients. What this plugin publishes (when REST `auto_away` exists on a mains-powered Protect) is Nest's own presence hold-off — on the order of **ten minutes** after the room empties, clearing when activity is seen again. Do not build automations that expect pathlight-speed motion.
103
+ The [detailed documentation](docs/README-DETAILED.md#full-configuration-reference) explains each option and what happens when REST or Observe is down.
113
104
 
114
105
  ## Not Working?
115
106
 
116
- 1. **Authentication error** — Token missing, truncated, Google JWT/`ya29.`, or revoked. Capture a fresh Nest Account token ([docs/AUTH.md](https://github.com/tbaur/homebridge-mynest/blob/main/docs/AUTH.md)).
117
- 2. **Thermostat missing** — Modern thermostats are Observe-only on some accounts. With default logging the plugin warns within about a minute if Observe produced no frames, and again every five minutes if a connected stream goes quiet; set `debug: true` for stream detail. From a git checkout you can also run `npm run verify` against the live account — see [DEVELOPMENT.md](https://github.com/tbaur/homebridge-mynest/blob/main/DEVELOPMENT.md).
118
- 3. **Thermostat in Settings but no room tile** — Remove and re-add the **MyNest** child bridge in the Home app (or whatever you set as **Name**), then assign rooms again. Same-UUID republish does not clear Apple Home's stuck presentation.
119
- 4. **Eco / setpoint changes snap back** — Enable **Allow thermostat control**. With control off, HomeKit can still move the UI (required for tiles) but Nest ignores the write and the plugin reverts.
120
- 5. **Protect without smoke/CO** — Likely Observe-only (missing from REST). The accessory still appears; alarm tiles wait for REST.
121
- 6. **No occupancy** — Battery Protect, power unknown, Observe-only Protect, or `exposeProtectOccupancy` off.
107
+ 1. **Authentication error:** token missing, truncated, Google JWT/`ya29.`, or revoked. Capture a fresh Nest Account token ([docs/AUTH.md](docs/AUTH.md)).
108
+ 2. **Thermostat missing:** modern thermostats are Observe-only on some accounts. Enable `debug` for stream detail.
109
+ 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.
122
112
 
123
- ## Security
113
+ The [full troubleshooting list](docs/README-DETAILED.md#troubleshooting) covers more cases, including a stuck Home app tile.
124
114
 
125
- This plugin holds a Nest Account `access_token` in Homebridge's plaintext `config.json`. That token is account-scoped Nest credentials — anyone who can read the file can act as the Nest web app for the home. Secure the host, prefer rotating the token if it may have leaked, and never paste tokens into issues or logs.
115
+ ## Security
126
116
 
127
- Details: [SECURITY.md](https://github.com/tbaur/homebridge-mynest/blob/main/SECURITY.md) and [docs/AUTH.md](https://github.com/tbaur/homebridge-mynest/blob/main/docs/AUTH.md).
117
+ This plugin holds a Nest Account `access_token` in Homebridge's plaintext `config.json`. Anyone who can read that file can act as the Nest web app for the home. Secure the host, rotate the token if it may have leaked, and never paste tokens into issues or logs.
128
118
 
129
- Nest publishes no consumer API and can change or revoke sessions without notice. This plugin uses Nest Account tokens only and rejects Google cookie / `ya29.` shapes on purpose.
119
+ See [SECURITY.md](SECURITY.md) and [docs/AUTH.md](docs/AUTH.md).
130
120
 
131
121
  ## Requirements
132
122
 
133
- - **Homebridge 2.x** — Homebridge 1.x is not supported
134
- - **Node.js 22 or 24** — this mirrors Homebridge 2's own `engines` constraint, so newer Node versions are excluded until Homebridge supports them; CI tests both
123
+ - **Homebridge 2.x** (Homebridge 1.x is not supported)
124
+ - **Node.js 22, 24, or 26**
135
125
  - A Nest Account (not Google-only) with an `access_token` from [home.nest.com/session](https://home.nest.com/session)
136
126
 
137
127
  ## More Info
138
128
 
139
- - [Authentication](https://github.com/tbaur/homebridge-mynest/blob/main/docs/AUTH.md) — capturing the Nest Account token
140
- - [Protocol notes](https://github.com/tbaur/homebridge-mynest/blob/main/docs/PROTOCOL.md) — reverse-engineered Nest behaviour
141
- - [Development](https://github.com/tbaur/homebridge-mynest/blob/main/DEVELOPMENT.md) — architecture and local setup
142
- - [Contributing](https://github.com/tbaur/homebridge-mynest/blob/main/CONTRIBUTING.md)
143
- - [Code of conduct](https://github.com/tbaur/homebridge-mynest/blob/main/CODE_OF_CONDUCT.md)
144
- - [Security policy](https://github.com/tbaur/homebridge-mynest/blob/main/SECURITY.md)
145
- - [Changelog](https://github.com/tbaur/homebridge-mynest/blob/main/CHANGELOG.md)
129
+ - [Detailed documentation](docs/README-DETAILED.md)
130
+ - [Authentication](docs/AUTH.md)
131
+ - [Protocol notes](docs/PROTOCOL.md)
132
+ - [Development](DEVELOPMENT.md)
133
+ - [Contributing](CONTRIBUTING.md)
134
+ - [Security policy](SECURITY.md)
135
+ - [Changelog](CHANGELOG.md)
146
136
  - [Report issues](https://github.com/tbaur/homebridge-mynest/issues)
147
137
 
148
138
  ## License
@@ -6,18 +6,17 @@
6
6
  "footerDisplay": "Thermostat mode/setpoints/Eco write through Nest BatchUpdateState only when Allow thermostat control is enabled (off by default). Each thermostat gets an Eco Mode switch; optionally enable Expose global Eco switch for all-at-once. Protect occupancy is Nest's ~10-minute presence verdict (auto_away), not motion. Treat the access token like a password.",
7
7
  "schema": {
8
8
  "type": "object",
9
+ "required": ["name", "accessToken"],
9
10
  "properties": {
10
11
  "name": {
11
12
  "title": "Name",
12
13
  "type": "string",
13
14
  "default": "MyNest",
14
- "required": true,
15
15
  "description": "Name shown in the Homebridge log."
16
16
  },
17
17
  "accessToken": {
18
18
  "title": "Nest access token",
19
19
  "type": "string",
20
- "required": true,
21
20
  "minLength": 32,
22
21
  "x-schema-form": {
23
22
  "type": "password"
@@ -52,7 +51,6 @@
52
51
  "title": "Ignored device IDs",
53
52
  "type": "array",
54
53
  "default": [],
55
- "required": false,
56
54
  "description": "Nest device IDs or serial numbers to leave out of HomeKit. IDs appear in the Homebridge log when devices are added.",
57
55
  "items": {
58
56
  "type": "string",
@@ -148,6 +148,12 @@ class GlobalEcoAccessory {
148
148
  this.#clearPending();
149
149
  this.#log.warn(`${settings_1.GLOBAL_ECO_DISPLAY_NAME}: Eco update failed: ${(0, sanitizers_1.sanitizeError)(error)}`);
150
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
153
+ // watching a switch flip back with no stated reason. The deferred refresh
154
+ // clears HAP's error status a tick later, so it cannot stick.
155
+ const { HAPStatus, HapStatusError } = this.#platform.api.hap;
156
+ throw new HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
151
157
  }
152
158
  }
153
159
  #applyAccessoryInformation() {
@@ -25,6 +25,23 @@ function midpoint(low, high) {
25
25
  }
26
26
  return (low + high) / 2;
27
27
  }
28
+ /**
29
+ * A Nest setpoint on the grid this accessory publishes.
30
+ *
31
+ * HAP quantizes and clamps whatever it is handed against the characteristic's
32
+ * props, so pushing Nest's raw Celsius leaves HomeKit holding a number the
33
+ * plugin never computed. A thermostat set to Fahrenheit makes that the normal
34
+ * case rather than an edge one: it stores 72 °F as 22.222 °C, and every push
35
+ * lands a quarter degree from what the Nest app shows. Doing the arithmetic
36
+ * here keeps `onGet`, plugin state, and HomeKit's cache on one number.
37
+ *
38
+ * Quantize first and anchor the grid at {@link MIN_SETPOINT_C}, because that is
39
+ * what HAP does with `minStep` and `minValue`.
40
+ */
41
+ function toPublishedSetpoint(celsius) {
42
+ const steps = Math.round((celsius - settings_1.MIN_SETPOINT_C) / settings_1.SETPOINT_STEP_C);
43
+ return (0, thermostat_write_1.clampSetpoint)(steps * settings_1.SETPOINT_STEP_C + settings_1.MIN_SETPOINT_C);
44
+ }
28
45
  class ThermostatAccessory extends base_1.NestAccessory {
29
46
  #service;
30
47
  #ecoService = null;
@@ -120,9 +137,18 @@ class ThermostatAccessory extends base_1.NestAccessory {
120
137
  * Send a Nest write when control is enabled; otherwise refresh so HomeKit
121
138
  * does not keep a slider position Nest never accepted.
122
139
  *
123
- * Nest errors are logged and the last good values are pushed back — never
124
- * rethrown into HAP `onSet`, which would mark the accessory "No Response"
125
- * for a long sticky period in the Home app.
140
+ * A refused write and a failed one are different events and must not look
141
+ * alike. Refused means `allowThermostatControl` is off: nothing malfunctioned,
142
+ * the plugin is doing what it was configured to do, so `onSet` resolves and
143
+ * the last good values are pushed back.
144
+ *
145
+ * A write that reached Nest and failed is reported as a HAP communication
146
+ * failure, so the user learns their change was dropped instead of watching a
147
+ * slider spring back for no stated reason — and an automation awaiting the
148
+ * write sees it fail rather than succeed. Safe here only because every
149
+ * characteristic is bound with an `onGet` handler and the deferred refresh
150
+ * pushes a value a tick later: HAP resets the error status on both paths, so
151
+ * the failure cannot settle into a sticky "No Response".
126
152
  */
127
153
  async #write(patch) {
128
154
  try {
@@ -137,6 +163,11 @@ class ThermostatAccessory extends base_1.NestAccessory {
137
163
  catch (error) {
138
164
  this.log.warn(`${this.identity.name}: thermostat update failed: ${(0, sanitizers_1.sanitizeError)(error)}`);
139
165
  this.#revertHomeKitValues();
166
+ // `HapStatusError` rather than the Nest error: rethrowing that would put a
167
+ // stack trace in the Homebridge log through HAP's unhandled-error path,
168
+ // and Nest's message is already logged above without the token in it.
169
+ const { HAPStatus, HapStatusError } = this.platform.api.hap;
170
+ throw new HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
140
171
  }
141
172
  }
142
173
  /**
@@ -179,6 +210,11 @@ class ThermostatAccessory extends base_1.NestAccessory {
179
210
  },
180
211
  });
181
212
  }
213
+ /**
214
+ * Send an Eco write, drawing the same line between refused and failed as
215
+ * {@link #write}: a refusal is the configured behaviour and resolves, while a
216
+ * write that reached Nest and failed is reported as a communication failure.
217
+ */
182
218
  async #writeEco(ecoOn) {
183
219
  try {
184
220
  const sent = await this.platform.applyEcoWrite(this.deviceId, ecoOn);
@@ -192,6 +228,8 @@ class ThermostatAccessory extends base_1.NestAccessory {
192
228
  catch (error) {
193
229
  this.log.warn(`${this.identity.name}: Eco update failed: ${(0, sanitizers_1.sanitizeError)(error)}`);
194
230
  this.#revertHomeKitValues();
231
+ const { HAPStatus, HapStatusError } = this.platform.api.hap;
232
+ throw new HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
195
233
  }
196
234
  }
197
235
  /**
@@ -230,7 +268,7 @@ class ThermostatAccessory extends base_1.NestAccessory {
230
268
  const characteristic = this.binder.bind(this.#service, type, () => {
231
269
  const value = read();
232
270
  if (value !== undefined) {
233
- return value;
271
+ return toPublishedSetpoint(value);
234
272
  }
235
273
  const current = this.#service.getCharacteristic(type).value;
236
274
  return typeof current === 'number'
@@ -240,8 +278,21 @@ class ThermostatAccessory extends base_1.NestAccessory {
240
278
  ? current
241
279
  : settings_1.MIN_SETPOINT_C;
242
280
  }, options.write ? { write: options.write } : {});
243
- if (typeof characteristic.value === 'number' && characteristic.value < settings_1.MIN_SETPOINT_C) {
244
- characteristic.updateValue(settings_1.MIN_SETPOINT_C);
281
+ // Correct both bounds; only the floor used to be. `setProps` clamps a
282
+ // cached value into the range itself, but it reports doing so, and Heating
283
+ // Threshold arrives holding HAP's default of 0 °C on every fresh
284
+ // thermostat, so pre-correcting is what keeps that off the log.
285
+ //
286
+ // Push only when the clamp actually moves the value. `updateValue` runs
287
+ // before the props below and is validated against the ones still in force,
288
+ // whose Heating Threshold ceiling is 25 °C, so re-sending an already-legal
289
+ // 30 °C would push it down to 25 instead of leaving it alone.
290
+ const cached = characteristic.value;
291
+ if (typeof cached === 'number') {
292
+ const clamped = (0, thermostat_write_1.clampSetpoint)(cached);
293
+ if (clamped !== cached) {
294
+ characteristic.updateValue(clamped);
295
+ }
245
296
  }
246
297
  characteristic.setProps({
247
298
  minValue: settings_1.MIN_SETPOINT_C,
@@ -65,3 +65,12 @@ export declare function formatThermostatUpdateLog(write: ThermostatSetpointWrite
65
65
  export declare function encodeEcoModeBatchUpdate(resourceId: string, ecoOn: boolean): Buffer;
66
66
  /** Encode a NestMessage suitable for TraitBatchApi/BatchUpdateState. */
67
67
  export declare function encodeTargetTemperatureBatchUpdate(write: ThermostatSetpointWrite): Buffer;
68
+ /**
69
+ * Confine a Celsius setpoint to the range Nest accepts.
70
+ *
71
+ * Exported because the accessory's publish path needs the same bounds: the
72
+ * range HomeKit is told about and the range a write is clamped to must come
73
+ * from one place, or a value the plugin publishes can be one it would refuse
74
+ * to send back.
75
+ */
76
+ export declare function clampSetpoint(celsius: number): number;
@@ -20,6 +20,7 @@ exports.buildThermostatSetpointWrite = buildThermostatSetpointWrite;
20
20
  exports.formatThermostatUpdateLog = formatThermostatUpdateLog;
21
21
  exports.encodeEcoModeBatchUpdate = encodeEcoModeBatchUpdate;
22
22
  exports.encodeTargetTemperatureBatchUpdate = encodeTargetTemperatureBatchUpdate;
23
+ exports.clampSetpoint = clampSetpoint;
23
24
  const node_crypto_1 = require("node:crypto");
24
25
  const settings_1 = require("../settings");
25
26
  const protobuf_1 = require("./protobuf");
@@ -223,6 +224,14 @@ function resolveStandbyMode(state, patchMode) {
223
224
  }
224
225
  return state.canCool && !state.canHeat ? 'cool' : 'heat';
225
226
  }
227
+ /**
228
+ * Confine a Celsius setpoint to the range Nest accepts.
229
+ *
230
+ * Exported because the accessory's publish path needs the same bounds: the
231
+ * range HomeKit is told about and the range a write is clamped to must come
232
+ * from one place, or a value the plugin publishes can be one it would refuse
233
+ * to send back.
234
+ */
226
235
  function clampSetpoint(celsius) {
227
236
  return Math.min(settings_1.MAX_SETPOINT_C, Math.max(settings_1.MIN_SETPOINT_C, celsius));
228
237
  }
@@ -18,10 +18,6 @@ function diagnosticLabel(msg) {
18
18
  return 'Diagnostics start';
19
19
  case 'diagnostics.stop':
20
20
  return 'Diagnostics stop';
21
- case 'health.degraded':
22
- return 'Health degraded';
23
- case 'health.recovered':
24
- return 'Health recovered';
25
21
  default:
26
22
  return msg;
27
23
  }
package/dist/platform.js CHANGED
@@ -884,22 +884,20 @@ class MyNestPlatform {
884
884
  this.#diagnosticsTimer = setInterval(() => this.#diagnosticsHeartbeat(), interval);
885
885
  this.#diagnosticsTimer.unref?.();
886
886
  }
887
+ /**
888
+ * Emits one heartbeat. The line is a warning on the interval where health
889
+ * flips from healthy to degraded, and info otherwise. A reader failure stays
890
+ * inside the timer so it cannot crash Homebridge.
891
+ */
887
892
  #diagnosticsHeartbeat() {
888
893
  if (!this.#diagnostics) {
889
894
  return;
890
895
  }
891
896
  try {
892
897
  const report = this.#diagnostics.buildHeartbeat(this.#buildDiagnosticsReaders());
893
- this.#emitDiagnostic('info', report);
894
898
  const health = report.lifecycle.health;
895
- if (this.#lastDiagnosticsHealth !== null && health !== this.#lastDiagnosticsHealth) {
896
- const isDegraded = health === 'degraded';
897
- const transition = {
898
- ...report,
899
- msg: isDegraded ? 'health.degraded' : 'health.recovered',
900
- };
901
- this.#emitDiagnostic(isDegraded ? 'warn' : 'info', transition);
902
- }
899
+ const becameDegraded = this.#lastDiagnosticsHealth === 'healthy' && health === 'degraded';
900
+ this.#emitDiagnostic(becameDegraded ? 'warn' : 'info', report);
903
901
  this.#lastDiagnosticsHealth = health;
904
902
  }
905
903
  catch (error) {
@@ -336,7 +336,15 @@ export declare const MAX_DIAGNOSTICS_INTERVAL_SEC = 86400;
336
336
  export declare const PROTECT_OCCUPANCY_HOLD_OFF_SEC = 600;
337
337
  /** Floor on the thermostat setpoint HomeKit may request, in Celsius. */
338
338
  export declare const MIN_SETPOINT_C = 9;
339
- /** Ceiling on the thermostat setpoint HomeKit may request, in Celsius. */
339
+ /**
340
+ * Ceiling on the thermostat setpoint HomeKit may request, in Celsius.
341
+ *
342
+ * Nest's own ceiling is 90 °F, or 32.222 °C, which does not sit on the
343
+ * {@link SETPOINT_STEP_C} grid. HAP quantizes a value onto that grid *before*
344
+ * range-checking it, so 32.222 arrives as 32.0 and needs no headroom above 32
345
+ * to publish cleanly. Raising this to 32.5 would only add a setpoint the Home
346
+ * app can offer and Nest cannot honour.
347
+ */
340
348
  export declare const MAX_SETPOINT_C = 32;
341
349
  /** Granularity of thermostat setpoints in Celsius, matching Nest's own UI. */
342
350
  export declare const SETPOINT_STEP_C = 0.5;
package/dist/settings.js CHANGED
@@ -362,7 +362,15 @@ exports.MAX_DIAGNOSTICS_INTERVAL_SEC = 86_400;
362
362
  exports.PROTECT_OCCUPANCY_HOLD_OFF_SEC = 600;
363
363
  /** Floor on the thermostat setpoint HomeKit may request, in Celsius. */
364
364
  exports.MIN_SETPOINT_C = 9;
365
- /** Ceiling on the thermostat setpoint HomeKit may request, in Celsius. */
365
+ /**
366
+ * Ceiling on the thermostat setpoint HomeKit may request, in Celsius.
367
+ *
368
+ * Nest's own ceiling is 90 °F, or 32.222 °C, which does not sit on the
369
+ * {@link SETPOINT_STEP_C} grid. HAP quantizes a value onto that grid *before*
370
+ * range-checking it, so 32.222 arrives as 32.0 and needs no headroom above 32
371
+ * to publish cleanly. Raising this to 32.5 would only add a setpoint the Home
372
+ * app can offer and Nest cannot honour.
373
+ */
366
374
  exports.MAX_SETPOINT_C = 32;
367
375
  /** Granularity of thermostat setpoints in Celsius, matching Nest's own UI. */
368
376
  exports.SETPOINT_STEP_C = 0.5;
package/docs/AUTH.md ADDED
@@ -0,0 +1,28 @@
1
+ # Authentication
2
+
3
+ Install and options: [README](../README.md). Full options and troubleshooting: [README-DETAILED.md](README-DETAILED.md).
4
+
5
+ This plugin supports **Nest Account** access tokens only. Google account sign-in (browser cookies, `issueToken`, `ya29.` OAuth tokens, JWTs) is intentionally not implemented.
6
+
7
+ Nest publishes no consumer API for this path. The plugin opens a session the same way the Nest web app does: `GET https://home.nest.com/session` with `Authorization: Basic <access_token>`, then uses the session `access_token`, `userid`, and `urls.transport_url` for REST and Observe.
8
+
9
+ ## Getting the token
10
+
11
+ 1. Sign in at [home.nest.com](https://home.nest.com) with a Nest Account (the pre-Google Nest login lineage).
12
+ 2. In the same browser, open [https://home.nest.com/session](https://home.nest.com/session).
13
+ 3. Copy the `access_token` field from the JSON response.
14
+ 4. Paste it into the plugin's **Nest access token** field (or into `NEST_ACCESS_TOKEN` for `npm run verify`).
15
+
16
+ You want the Nest Account token from that session JSON — not a Google cookie, not a JWT (`eyJ…`), and not a `ya29.` OAuth token.
17
+
18
+ ## Treat it as a password
19
+
20
+ Anyone with this token can control every thermostat and smoke alarm on the Nest Account. Homebridge stores it in plaintext in `config.json`.
21
+
22
+ - Do not paste it into issues, logs, screenshots, or chat.
23
+ - Prefer a Nest Account used only for Homebridge when that is practical.
24
+ - If it leaks, revoke Nest sessions / rotate credentials from Nest's account settings, then capture a fresh token.
25
+
26
+ When Nest rejects the token you will see an authentication error in the Homebridge log and the plugin will stop retrying until you fix the config and restart.
27
+
28
+ The wire protocol around the session is in [PROTOCOL.md](PROTOCOL.md). The threat model is in [SECURITY.md](../SECURITY.md).
@@ -0,0 +1,103 @@
1
+ # Protocol notes
2
+
3
+ User-facing options and troubleshooting: [README-DETAILED.md](README-DETAILED.md). Token capture: [AUTH.md](AUTH.md).
4
+
5
+ Nest publishes no consumer API. Everything below was confirmed with a probe kit against a live Nest Account. Treat this as a working map of private backends, not a promise Nest will keep them stable.
6
+
7
+ ## Dual transport
8
+
9
+ | Path | Role |
10
+ | --- | --- |
11
+ | REST `app_launch` + `/v5/subscribe` | Protect alarm state (`topaz`), structure/where, temperature sensors (`kryptonite`), older thermostat buckets when present |
12
+ | Observe (HTTP/2 gRPC-web protobuf) | Source of truth for modern thermostats; complete Protect inventory; trait patches for HVAC |
13
+
14
+ Neither transport is a superset of the other. On the account used to build this plugin, REST returned six Protects and zero thermostats while Observe returned seven Protects and five thermostats — and REST still claimed `num_thermostats: "5+"`. The plugin unions Observe ∪ REST and records which transport supplied each device.
15
+
16
+ **Critical merge rule:** Observe is authoritative for thermostats and for the complete Protect list. One Protect (post-reset) can be Observe-only and missing from REST `topaz`/`swarm`; it must still appear in HomeKit.
17
+
18
+ ## Accessory removal
19
+
20
+ HomeKit accessories are unregistered only when Nest has confirmed a device is gone — never because a transport errored, a breaker opened, or a boot raced REST ahead of Observe.
21
+
22
+ | Signal | Behaviour |
23
+ | --- | --- |
24
+ | Failed REST / Observe request | Keep last inventory; no unregister |
25
+ | REST `app_launch` empty or under half prior size | Treat as truncated; merge present keys; keep missing |
26
+ | REST `app_launch` omits a key | Two consecutive complete omissions before drop |
27
+ | Observe reconnect empty or under half prior devices | Treat as incomplete; keep prior Observe state |
28
+ | Observe omits a `DEVICE_*` | Two consecutive complete snapshots before prune |
29
+ | Observe never connected this session | Do not unregister (REST-only view cannot prove thermostats are gone) |
30
+ | Device absent from the Observe ∪ REST inventory after the above | Unregister from HomeKit |
31
+ | Expired / revoked access token (or both transports exhausted on HTTP 403) | Stop updates; **keep** accessories — Nest Account auth is a manually pasted session token, so unregistering would bounce rooms/automations until the user pastes a fresh one and restarts |
32
+
33
+ Unusable plugin config at startup (missing/invalid token shape, etc.) **keeps** the cached accessories, for the same reason an expired token does. A typo, a half-edited `config.json`, or a truncated token would otherwise destroy every room assignment, scene membership, and automation target in the Home app — and fixing the config does not bring them back, because HomeKit treats the re-registered accessories as new devices. A config error cannot warrant a more destructive response than a revoked token. The plugin logs why and publishes nothing until it is restarted with a usable config.
34
+
35
+ A transient failure at startup (DNS, TLS, a timeout, a 5xx) is **not** fatal: it is retried with backoff, because `didFinishLaunching` fires only once and a host that boots before its network is up would otherwise stay idle for the lifetime of the process.
36
+
37
+ ## Session
38
+
39
+ 1. Config holds Nest Account `access_token` from `https://home.nest.com/session`.
40
+ 2. Plugin calls `GET /session` with `Authorization: Basic <token>` and the web app's `cztoken` cookie.
41
+ 3. Response yields session `access_token`, `userid`, and `urls.transport_url`.
42
+ 4. REST calls use Basic auth with the session token; Observe uses the same token against `grpc-web.production.nest.com`.
43
+
44
+ ## REST
45
+
46
+ - `POST /api/0.1/user/{userid}/app_launch` with bucket types including `topaz`, `structure`, `kryptonite`, `device`, `shared`, `where`, …
47
+ - Long-poll: `POST {transport_url}/v5/subscribe` with `{ objects: [{ object_key, object_revision, object_timestamp }] }`, client timeout ~120s. An idle abort is normal, not a failure.
48
+
49
+ ## Observe
50
+
51
+ - Host: `grpc-web.production.nest.com`, path from session endpoints (`GatewayService/Observe`).
52
+ - Request body: opaque traits blob vendored from the probe kit (`assets/protobuf/ObserveTraits.protobuf`).
53
+ - Framing: length-delimited `nest.rpc.StreamBody`. Decode the full framed buffer; do not strip a header and assume success.
54
+ - Frame 0 is often a resource/trait catalog that fails NestMessage decode — ignore it.
55
+ - Later frames are trait snapshots/patches. Nest sends deltas; merge patches into state.
56
+
57
+ ## Protect occupancy
58
+
59
+ A Protect has a PIR sensor, but neither API exposes usable motion events in practice. A ~12.5-hour capture of both transports on an occupied house recorded zero REST `auto_away` flips and zero nonempty Observe `ambient_motion` deltas. Many false "MOTION" banners in naive parsers were reconnect baselines containing the schema string `AmbientMotionEvent`, not live PIR.
60
+
61
+ What Nest does publish is `auto_away` on mains-powered Protects in REST `topaz`: roughly a ten-minute presence hold-off (`auto_away_decision_time_secs` is the window constant, not a timestamp). HomeKit occupancy is therefore:
62
+
63
+ - Published only when REST `auto_away` exists **and** the unit is confirmed mains-powered (`line_power_present` / Observe `wall_power`).
64
+ - Unavailable when power is unknown, for battery Protects, and for Observe-only Protects until a real event path is proven.
65
+ - Documented as presence, not pathlight-speed motion.
66
+
67
+ ## Protect smoke / CO
68
+
69
+ Alarm status is taken from REST `topaz` (`smoke_status` / `co_status`), and only while the REST feed can refresh it. Observe streams `safety_alarm_*` traits, but no public schema maps them and captured samples have all been all-clear. Inferring "no smoke" from an unverified enum is refused.
70
+
71
+ An Observe-only Protect gets no smoke/CO HomeKit services until REST reports alarm state. If REST later becomes unavailable (circuit breaker open, HTTP 403 budget exhausted, or no successful subscribe/`app_launch` within the stale window), smoke/CO services stay published with last-known values but `StatusActive=false` and `StatusFault=GENERAL_FAULT` — leaving a frozen clear that still looks live would be dishonest, and tearing services down would break HomeKit rooms and automations on every Nest blip. Battery / online may still appear from Observe.
72
+
73
+ ## Thermostat writes
74
+
75
+ HVAC state updates arrive over Observe (`target_temperature_settings` / `hvac_control` / `eco_mode_state`). Writes use the same protobuf gateway as the Nest web app:
76
+
77
+ ### Mode / setpoints
78
+
79
+ 1. Encode `nest.trait.hvac.TargetTemperatureSettingsTrait` (mode via `settings.hvacMode` + `active`; setpoints as `targetTemperatureHeat` / `targetTemperatureCool`).
80
+ 2. Wrap in `nest.rpc.NestMessage` `{ set: [{ object: { id: DEVICE_…, key: target_temperature_settings, uuid }, property: Any }] }`.
81
+ 3. `POST https://grpc-web.production.nest.com/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState` with `Content-Type: application/x-protobuf` and the session Basic token.
82
+
83
+ `off` is `active=0` with a standby `HEAT`/`COOL` left in `hvacMode` — Nest does not store an OFF enum there. REST `/v5/put` cannot reach Observe-only thermostats.
84
+
85
+ Plugin flag: `allowThermostatControl` (default **off**). Manual HomeKit setpoint/mode writes clear Nest Eco (`eco_mode_state` OFF) in the same batch when Eco was active.
86
+
87
+ > The encode shape was confirmed against a live account with a maintainer-only probe kit (`nest-probe`), which is **not part of this repository** and is not something a user or contributor can run. Treat references to it as provenance for where these findings came from, not as a step to reproduce.
88
+
89
+ ### Eco Mode (on / off)
90
+
91
+ HomeKit has no Eco thermostat mode, so the plugin exposes Eco as a Switch (per thermostat, plus optional house-wide `exposeGlobalEcoSwitch`). Writes are a separate BatchUpdateState body:
92
+
93
+ 1. Encode `nest.trait.hvac.EcoModeStateTrait` with `ecoEnabled` `ON` or `OFF` and `ecoModeChangeReason` `ECO_MODE_CHANGE_REASON_MANUAL`.
94
+ 2. Wrap in `nest.rpc.NestMessage` `{ set: [{ object: { id: DEVICE_…, key: eco_mode_state, uuid }, property: Any }] }` (`encodeEcoModeBatchUpdate`).
95
+ 3. Same `TraitBatchApi/BatchUpdateState` endpoint and auth as setpoint writes.
96
+
97
+ Eco-only writes were never confirmed against a live account the way setpoints were; enable `allowThermostatControl` only if you are comfortable with live Nest writes on your account.
98
+
99
+ The optional house-wide switch (`exposeGlobalEcoSwitch`) posts Eco to every live thermostat accessory. It reports success only when every write succeeds; HomeKit stays optimistic until Nest's all-Eco aggregate matches (or 45s elapses and Nest truth wins).
100
+
101
+ ## Homebridge 2 update path
102
+
103
+ Never push live values with removed `Characteristic.getValue()`, and never call `updateValue(characteristic.value)` (that is the cached value). Store the getter next to the characteristic and call `updateValue(getFunc())` on Nest updates. See `src/utils/bound-characteristics.ts`.
@@ -0,0 +1,135 @@
1
+ # homebridge-mynest detailed documentation
2
+
3
+ Install and a short options table live in the [README](../README.md). This page is the rest: every option, what HomeKit shows when Nest is quiet, and how to read a failure.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Full configuration reference](#full-configuration-reference)
8
+ - [How devices appear](#how-devices-appear)
9
+ - [Protect occupancy](#protect-occupancy)
10
+ - [Smoke and CO honesty](#smoke-and-co-honesty)
11
+ - [How it works](#how-it-works)
12
+ - [Diagnostics](#diagnostics)
13
+ - [Troubleshooting](#troubleshooting)
14
+ - [Related docs](#related-docs)
15
+
16
+ ## Full configuration reference
17
+
18
+ ```json
19
+ {
20
+ "platforms": [
21
+ {
22
+ "platform": "MyNest",
23
+ "name": "MyNest",
24
+ "accessToken": "paste-access_token-from-home.nest.com/session",
25
+ "allowThermostatControl": false,
26
+ "exposeGlobalEcoSwitch": false,
27
+ "exposeProtectOccupancy": true,
28
+ "exposeProtectTemperature": false,
29
+ "ignoredDeviceIds": [],
30
+ "fieldTest": false,
31
+ "diagnosticsInterval": 0,
32
+ "structuredLogs": false,
33
+ "debug": false
34
+ }
35
+ ]
36
+ }
37
+ ```
38
+
39
+ | Option | Default | Description |
40
+ | --- | --- | --- |
41
+ | `name` | `MyNest` | Required by Homebridge. Log prefix for this instance. |
42
+ | `accessToken` | — | Required. Nest Account `access_token` from [AUTH.md](AUTH.md). Google JWT and `ya29.` shapes are rejected. |
43
+ | `allowThermostatControl` | `false` | Opt in to send mode, setpoint, and Eco changes through Nest BatchUpdateState. With this off, HomeKit can still move the tile (Home needs that) but Nest ignores the write and the plugin reverts. |
44
+ | `exposeGlobalEcoSwitch` | `false` | Publish a Nest Eco Mode switch that sets Eco on every thermostat. Writes still require `allowThermostatControl`. |
45
+ | `exposeProtectOccupancy` | `true` | Occupancy from REST `auto_away` when Nest computes it on a mains-powered Protect. See [Protect occupancy](#protect-occupancy). |
46
+ | `exposeProtectTemperature` | `false` | Temperature and humidity measured by each Protect. |
47
+ | `ignoredDeviceIds` | `[]` | Device IDs or serials to leave out of HomeKit. |
48
+ | `fieldTest` | `false` | Use Nest field-test hosts. |
49
+ | `diagnosticsInterval` | `0` | Seconds between health heartbeats in the log. `0` is off. Otherwise `30`–`86400` (24h). |
50
+ | `structuredLogs` | `false` | When diagnostics are on, also emit a machine-readable JSON line next to each human summary. |
51
+ | `debug` | `false` | Verbose logging. Tokens are redacted. |
52
+
53
+ `name` is required by Homebridge verified plugins. The plugin uses it as the log prefix.
54
+
55
+ ## How devices appear
56
+
57
+ | Nest device | HomeKit accessory | Notes |
58
+ | --- | --- | --- |
59
+ | Thermostat | Thermostat + Eco Mode switch | Observe is source of truth. Modern thermostats may be missing from REST entirely. Mode, setpoints, and Eco are writable only when `allowThermostatControl` is on. Optional house-wide Eco switch via `exposeGlobalEcoSwitch`. |
60
+ | Nest Protect | Smoke + CO (+ optional occupancy / temp) | Smoke and CO need REST `topaz`. Battery and online can come from REST and/or Observe. |
61
+ | Temperature Sensor | Temperature Sensor | Battery + temperature (`kryptonite`). |
62
+
63
+ Cameras, doorbells, Yale locks, Home/Away structure switches, and Google-account-only homes are out of scope. That list is also in [SECURITY.md](../SECURITY.md).
64
+
65
+ A device leaves HomeKit only after Nest-confirmed drops (two-strike and truncation guards on Observe and REST). A cloud blip, a REST-only boot, or an expired access token stops updates but does not unregister thermostats, Protects, or temp sensors. Paste a fresh token and restart.
66
+
67
+ ## Protect occupancy
68
+
69
+ Nest does not expose a reliable Protect motion event stream to third-party clients.
70
+
71
+ What this plugin publishes (when REST `auto_away` exists on a mains-powered Protect) is Nest's own presence hold-off: on the order of **ten minutes** after the room empties, clearing when activity is seen again. Do not build automations that expect pathlight-speed motion.
72
+
73
+ Occupancy is omitted, rather than shown as a stuck empty house, when:
74
+
75
+ - the Protect is battery-powered
76
+ - power is unknown
77
+ - the Protect is Observe-only (no REST `auto_away`)
78
+ - `exposeProtectOccupancy` is off
79
+
80
+ If REST later goes stale, a last occupancy reading stays published but is marked inactive/faulted.
81
+
82
+ ## Smoke and CO honesty
83
+
84
+ Observe-only Protects omit smoke and CO until REST reports alarm state. The accessory still appears, so rooms and automations keep their targets.
85
+
86
+ If REST later goes down, those tiles stay in HomeKit but are marked inactive/faulted rather than freezing a live all-clear. Battery and online may remain from Observe.
87
+
88
+ The plugin does not invent an all-clear from unverified Observe fields.
89
+
90
+ ## How it works
91
+
92
+ Two transports, neither a superset of the other:
93
+
94
+ | Path | Role |
95
+ | --- | --- |
96
+ | REST `app_launch` + `/v5/subscribe` | Protect alarm state (`topaz`), structure/where, temperature sensors, older thermostat buckets when present |
97
+ | Observe (HTTP/2) | Source of truth for modern thermostats; complete Protect inventory |
98
+
99
+ The plugin unions Observe ∪ REST and records which transport supplied each device. Independent circuit breakers fail fast when Nest's edge returns sustained 5xx or network failures, then probe again after a short cooldown. Auth and 403 paths keep their own handling.
100
+
101
+ Architecture and the Homebridge 2 update path: [DEVELOPMENT.md](../DEVELOPMENT.md). Wire-level notes: [PROTOCOL.md](PROTOCOL.md).
102
+
103
+ ## Diagnostics
104
+
105
+ Set `diagnosticsInterval` to `30`–`86400` to log periodic health heartbeats and boot/shutdown snapshots. The heartbeat where health flips from healthy to degraded is a warning; later degraded heartbeats stay at info until it recovers. `0` (default) leaves diagnostics off.
106
+
107
+ `structuredLogs` adds a JSON line next to each human summary. Reports cover REST and Observe transport gauges, breaker state, device inventory, and API latency.
108
+
109
+ Nothing from diagnostics is exposed in HomeKit.
110
+
111
+ ## Troubleshooting
112
+
113
+ 1. **Authentication error.** Token missing, truncated, Google JWT/`ya29.`, or revoked. Capture a fresh Nest Account token ([AUTH.md](AUTH.md)).
114
+ 2. **Thermostat missing.** Modern thermostats are Observe-only on some accounts. With default logging the plugin warns within about a minute if Observe produced no frames, and again every five minutes if a connected stream goes quiet. Set `debug: true` for stream detail. From a git checkout you can also run `npm run verify` against the live account. See [DEVELOPMENT.md](../DEVELOPMENT.md).
115
+ 3. **Thermostat in Settings but no room tile.** Remove and re-add the **MyNest** child bridge in the Home app (or whatever you set as **Name**), then assign rooms again. Same-UUID republish does not clear Apple Home's stuck presentation.
116
+ 4. **Eco / setpoint changes snap back.** Enable **Allow thermostat control**. With control off, HomeKit can still move the UI (required for tiles) but Nest ignores the write and the plugin reverts.
117
+ 5. **Protect without smoke/CO.** Likely Observe-only (missing from REST). The accessory still appears; alarm tiles wait for REST.
118
+ 6. **No occupancy.** Battery Protect, power unknown, Observe-only Protect, or `exposeProtectOccupancy` off. See [Protect occupancy](#protect-occupancy).
119
+
120
+ Restart Homebridge after any `config.json` edit.
121
+
122
+ ## Related docs
123
+
124
+ - [README](../README.md): install and short options table
125
+ - [AUTH.md](AUTH.md): capturing the Nest Account token
126
+ - [PROTOCOL.md](PROTOCOL.md): reverse-engineered Nest behaviour
127
+ - [DEVELOPMENT.md](../DEVELOPMENT.md): architecture and local setup
128
+ - [SECURITY.md](../SECURITY.md)
129
+ - [CONTRIBUTING.md](../CONTRIBUTING.md)
130
+
131
+ ## License
132
+
133
+ Copyright 2026 tbaur
134
+
135
+ Licensed under the Apache License, Version 2.0. See [LICENSE](../LICENSE) file for details.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-mynest",
3
3
  "displayName": "Homebridge My Nest",
4
- "version": "2.0.0",
4
+ "version": "2.0.2",
5
5
  "description": "Homebridge plugin for Nest thermostats (with Eco Mode), Nest Protect, and Nest Temperature Sensors using a Nest Account access token",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -30,12 +30,16 @@
30
30
  },
31
31
  "homepage": "https://github.com/tbaur/homebridge-mynest#readme",
32
32
  "dependencies": {
33
- "protobufjs": "7.6.5"
33
+ "protobufjs": "7.6.6"
34
+ },
35
+ "overrides": {
36
+ "js-yaml": "^3.15.2",
37
+ "@homebridge/hap-nodejs": "$@homebridge/hap-nodejs"
34
38
  },
35
39
  "devDependencies": {
36
40
  "@homebridge/hap-nodejs": "^2.0.4",
37
41
  "@types/jest": "^30.0.0",
38
- "@types/node": "^22.0.0",
42
+ "@types/node": "^26.2.0",
39
43
  "@typescript-eslint/eslint-plugin": "^8.51.0",
40
44
  "@typescript-eslint/parser": "^8.51.0",
41
45
  "eslint": "^10.7.0",
@@ -43,12 +47,12 @@
43
47
  "homebridge": "^2.1.0",
44
48
  "jest": "^30.4.2",
45
49
  "nock": "^14.0.16",
46
- "ts-jest": "29.4.12",
50
+ "ts-jest": "29.4.14",
47
51
  "typescript": "6.0.3"
48
52
  },
49
53
  "engines": {
50
54
  "homebridge": "^2.0.0",
51
- "node": "^22 || ^24"
55
+ "node": "^22 || ^24 || ^26"
52
56
  },
53
57
  "peerDependencies": {
54
58
  "homebridge": "^2.0.0"
@@ -80,6 +84,8 @@
80
84
  "assets/**/*",
81
85
  "config.schema.json",
82
86
  "CHANGELOG.md",
83
- "SECURITY.md"
87
+ "SECURITY.md",
88
+ "README.md",
89
+ "docs/**/*"
84
90
  ]
85
91
  }