homebridge-withings-environment-data 0.1.5 → 0.3.1

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/README.md CHANGED
@@ -54,14 +54,38 @@ Fields:
54
54
  matching the scale's own upload cadence).
55
55
  - **CO2 Detected Threshold (ppm)**: ppm above which the CarbonDioxideSensor
56
56
  reports "abnormal" (default 1000).
57
+ - **ntfy Topic (optional)**: if set, sends a push notification via
58
+ [ntfy.sh](https://ntfy.sh) to this topic the first time a poll fails
59
+ (not repeated on every subsequent failure in the same streak — only once
60
+ a poll succeeds again does the next failure trigger a fresh
61
+ notification). Leave blank to disable.
62
+
63
+ ## How authentication works
64
+
65
+ The plugin reuses a long-lived (~1 week) `session_key`, the same
66
+ mechanism Withings' own web app relies on to stay logged in without
67
+ re-entering credentials each time.
68
+
69
+ That session is cached in a small file in Homebridge's own storage
70
+ directory, `withings-environment-data-session.json`, and reused across
71
+ polls and restarts. Email/password only get used as a fallback, the rare
72
+ times that cached session actually expires, and the fresh session that
73
+ fallback produces is automatically written back to the same file for next
74
+ time. In normal operation the plugin should hit the password endpoint very
75
+ infrequently, roughly weekly at most.
57
76
 
58
77
  ## When it stops working
59
78
 
60
- If the Homebridge log shows a "session not trusted (landed on confirm_totp)"
61
- error, the device-trust cookie has been invalidated (e.g. after a password
62
- change, or Withings revoking trusted devices). The Home app will keep
63
- showing the last known good reading, it won't go blank, but a fault
64
- indicator appears on the sensors until this is fixed. Fix:
79
+ Most of the time this is self-healing: if the cached session has expired,
80
+ the plugin automatically falls back to a full login and caches the new
81
+ session it gets back, no action needed. A fault indicator appears on the
82
+ sensors during a failed poll, but the Home app keeps showing the last known
83
+ good reading rather than going blank.
84
+
85
+ If the Homebridge log instead shows a "session not trusted (landed on
86
+ confirm_totp)" error, the *trust cookie* itself has been invalidated (e.g.
87
+ after a password change, or Withings revoking trusted devices) — this is
88
+ what the fallback login relies on, so it can't self-heal on its own. Fix:
65
89
 
66
90
  1. Recapture the trust cookie: see [Getting the trust
67
91
  cookie](#getting-the-trust-cookie) below.
@@ -48,6 +48,18 @@
48
48
  "type": "integer",
49
49
  "default": 1000,
50
50
  "minimum": 400
51
+ },
52
+ "noResponseAfterMissedPolls": {
53
+ "title": "No Response After Missed Polls",
54
+ "type": "integer",
55
+ "default": 2,
56
+ "minimum": 0,
57
+ "description": "Consecutive failed polls to tolerate (keeping the last known readings) before the Home app shows \"No Response\". 0 means show it immediately on any failure."
58
+ },
59
+ "ntfyTopic": {
60
+ "title": "ntfy Topic (optional)",
61
+ "type": "string",
62
+ "description": "If set, sends a push notification via ntfy.sh to this topic the first time a poll fails (not repeated on every subsequent failure in the same streak). Leave blank to disable."
51
63
  }
52
64
  }
53
65
  }
package/index.js CHANGED
@@ -1,6 +1,10 @@
1
- const { login } = require('./lib/login');
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { login, resumeSession } = require('./lib/login');
2
4
  const { discoverDevice } = require('./lib/discover');
3
5
 
6
+ const SESSION_STATE_FILENAME = 'withings-environment-data-session.json';
7
+
4
8
  const MEASURE_URL = 'https://scalews.withings.com/cgi-bin/v2/measure';
5
9
  // Reverse-engineered/unofficial: not part of the documented Withings API.
6
10
  const MEASTYPE_CO2 = 35;
@@ -81,9 +85,74 @@ class WithingsEnvironmentDataPlatform {
81
85
  this.deviceId = null;
82
86
  this.userId = null;
83
87
 
88
+ // Cached last-known reading, served by the onGet handlers below so the
89
+ // Home app keeps showing real data through transient poll failures.
90
+ // Only once missedCycles exceeds the configured threshold do those
91
+ // handlers throw, which is what actually makes the Home app show
92
+ // "No Response" (StatusFault alone isn't reliably surfaced there).
93
+ this.hasEverSucceeded = false;
94
+ this.missedCycles = 0;
95
+ this.lastReading = { co2: null, temperature: null };
96
+ // Only send one ntfy notification per failure streak, not on every
97
+ // missed poll — reset once a poll succeeds again.
98
+ this.hasNotifiedFailure = false;
99
+
100
+ // The long-lived (~1 week) session_key that lets us skip email/password/2FA
101
+ // entirely on most polls — see lib/login.js's resumeSession(). Persisted to
102
+ // disk so it survives Homebridge restarts, and only a full login() refreshes
103
+ // it, since repeatedly hitting the password endpoint appears to be heavily
104
+ // throttled by Withings.
105
+ this.sessionStatePath = path.join(this.api.user.storagePath(), SESSION_STATE_FILENAME);
106
+ this.sessionKey = this.loadSessionKey();
107
+
84
108
  this.api.on('didFinishLaunching', () => this.discoverDevices());
85
109
  }
86
110
 
111
+ loadSessionKey() {
112
+ try {
113
+ const raw = fs.readFileSync(this.sessionStatePath, 'utf8');
114
+ return JSON.parse(raw).sessionKey ?? null;
115
+ } catch (err) {
116
+ if (err.code !== 'ENOENT') {
117
+ this.log.warn(`Could not read session state file: ${err.message}`);
118
+ }
119
+ return null;
120
+ }
121
+ }
122
+
123
+ saveSessionKey(sessionKey) {
124
+ try {
125
+ fs.writeFileSync(this.sessionStatePath, JSON.stringify({ sessionKey }));
126
+ } catch (err) {
127
+ this.log.warn(`Could not persist session state file: ${err.message}`);
128
+ }
129
+ }
130
+
131
+ async authenticate() {
132
+ if (this.sessionKey) {
133
+ try {
134
+ return await resumeSession(this.sessionKey, this.config.trustCookieName, this.config.trustCookieValue);
135
+ } catch (err) {
136
+ this.log.warn(`Withings session resume failed, falling back to full login: ${err.message}`);
137
+ }
138
+ }
139
+
140
+ const result = await login(
141
+ this.config.email,
142
+ this.config.password,
143
+ this.config.trustCookieName,
144
+ this.config.trustCookieValue
145
+ );
146
+
147
+ if (result.sessionKey && result.sessionKey !== this.sessionKey) {
148
+ this.sessionKey = result.sessionKey;
149
+ this.saveSessionKey(result.sessionKey);
150
+ this.log.info('Withings full login succeeded; cached the new session for future polls.');
151
+ }
152
+
153
+ return result;
154
+ }
155
+
87
156
  configureAccessory(accessory) {
88
157
  this.log.info('Loading accessory from cache:', accessory.displayName);
89
158
  this.accessories.push(accessory);
@@ -122,6 +191,19 @@ class WithingsEnvironmentDataPlatform {
122
191
  this.temperatureService =
123
192
  accessory.getService(this.Service.TemperatureSensor) ||
124
193
  accessory.addService(this.Service.TemperatureSensor, 'Temperature', 'temperature');
194
+
195
+ this.co2Service
196
+ .getCharacteristic(this.Characteristic.CarbonDioxideLevel)
197
+ .onGet(() => this.getCo2LevelOrThrow());
198
+ this.co2Service
199
+ .getCharacteristic(this.Characteristic.CarbonDioxideDetected)
200
+ .onGet(() => this.getCo2DetectedOrThrow());
201
+ this.airQualityService
202
+ .getCharacteristic(this.Characteristic.AirQuality)
203
+ .onGet(() => this.getAirQualityOrThrow());
204
+ this.temperatureService
205
+ .getCharacteristic(this.Characteristic.CurrentTemperature)
206
+ .onGet(() => this.getTemperatureOrThrow());
125
207
  }
126
208
 
127
209
  startPolling() {
@@ -134,12 +216,7 @@ class WithingsEnvironmentDataPlatform {
134
216
 
135
217
  async poll() {
136
218
  try {
137
- const { cookieHeader, sessionToken } = await login(
138
- this.config.email,
139
- this.config.password,
140
- this.config.trustCookieName,
141
- this.config.trustCookieValue
142
- );
219
+ const { cookieHeader, sessionToken } = await this.authenticate();
143
220
 
144
221
  if (!this.deviceId || !this.userId) {
145
222
  const discovered = await discoverDevice(cookieHeader);
@@ -160,20 +237,89 @@ class WithingsEnvironmentDataPlatform {
160
237
 
161
238
  this.applyReading(co2, temperature);
162
239
  this.setFault(false);
240
+ this.missedCycles = 0;
241
+ this.hasNotifiedFailure = false;
163
242
  } catch (err) {
164
243
  // Deliberately do not touch the value characteristics here — the Home app
165
- // should keep showing the last known good reading, not go blank, when a
166
- // poll fails (e.g. the trust cookie expired and login needs recapturing).
167
- this.log.error(`Withings poll failed: ${err.message}`);
244
+ // should keep showing the last known good reading, not go blank, on a
245
+ // single poll failure (e.g. the trust cookie expired and login needs
246
+ // recapturing). Only once missedCycles crosses the configured threshold
247
+ // do the onGet handlers below start throwing, which is what actually
248
+ // surfaces "No Response" in the Home app.
249
+ this.missedCycles += 1;
250
+ this.log.error(`Withings poll failed (missed cycle ${this.missedCycles}): ${err.message}`);
168
251
  this.setFault(true);
252
+
253
+ if (!this.hasNotifiedFailure) {
254
+ this.hasNotifiedFailure = true;
255
+ await this.sendNtfyNotification(err.message);
256
+ }
169
257
  }
170
258
  }
171
259
 
172
- applyReading(co2, temperature) {
260
+ async sendNtfyNotification(errorMessage) {
261
+ const topic = this.config.ntfyTopic;
262
+ if (!topic) return;
263
+
264
+ try {
265
+ await fetch(`https://ntfy.sh/${encodeURIComponent(topic)}`, {
266
+ method: 'POST',
267
+ headers: { Title: 'Homebridge: Getting Withings Environment Data Failed!' },
268
+ body: `${errorMessage}\n\nThe Home app will keep showing the last known reading until this is resolved.`,
269
+ });
270
+ } catch (err) {
271
+ this.log.warn(`Failed to send ntfy notification: ${err.message}`);
272
+ }
273
+ }
274
+
275
+ getCo2Threshold() {
173
276
  const threshold = Number(this.config.co2DetectedThresholdPpm);
174
- const co2Threshold = Number.isFinite(threshold) && threshold > 0 ? threshold : 1000;
277
+ return Number.isFinite(threshold) && threshold > 0 ? threshold : 1000;
278
+ }
279
+
280
+ getNoResponseThreshold() {
281
+ const threshold = Number(this.config.noResponseAfterMissedPolls);
282
+ return Number.isFinite(threshold) && threshold >= 0 ? threshold : 2;
283
+ }
284
+
285
+ isStale() {
286
+ return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold();
287
+ }
288
+
289
+ throwIfStale() {
290
+ if (this.isStale()) {
291
+ throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
292
+ }
293
+ }
294
+
295
+ getCo2LevelOrThrow() {
296
+ this.throwIfStale();
297
+ return this.lastReading.co2;
298
+ }
299
+
300
+ getCo2DetectedOrThrow() {
301
+ this.throwIfStale();
302
+ return this.lastReading.co2 > this.getCo2Threshold()
303
+ ? this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_ABNORMAL
304
+ : this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_NORMAL;
305
+ }
306
+
307
+ getAirQualityOrThrow() {
308
+ this.throwIfStale();
309
+ return mapCo2ToAirQuality(this.lastReading.co2, this.Characteristic.AirQuality);
310
+ }
311
+
312
+ getTemperatureOrThrow() {
313
+ this.throwIfStale();
314
+ return this.lastReading.temperature;
315
+ }
316
+
317
+ applyReading(co2, temperature) {
318
+ const co2Threshold = this.getCo2Threshold();
175
319
 
176
320
  if (co2 !== null && co2 !== undefined) {
321
+ this.lastReading.co2 = co2;
322
+ this.hasEverSucceeded = true;
177
323
  this.co2Service.updateCharacteristic(this.Characteristic.CarbonDioxideLevel, co2);
178
324
  this.co2Service.updateCharacteristic(
179
325
  this.Characteristic.CarbonDioxideDetected,
@@ -188,6 +334,8 @@ class WithingsEnvironmentDataPlatform {
188
334
  }
189
335
 
190
336
  if (temperature !== null && temperature !== undefined) {
337
+ this.lastReading.temperature = temperature;
338
+ this.hasEverSucceeded = true;
191
339
  this.temperatureService.updateCharacteristic(this.Characteristic.CurrentTemperature, temperature);
192
340
  }
193
341
  }
package/lib/login.js CHANGED
@@ -34,6 +34,10 @@ class CookieJar {
34
34
  this.cookies.set(name, value);
35
35
  }
36
36
 
37
+ get(name) {
38
+ return this.cookies.get(name);
39
+ }
40
+
37
41
  toHeader() {
38
42
  return Array.from(this.cookies.entries())
39
43
  .map(([name, value]) => `${name}=${value}`)
@@ -91,12 +95,51 @@ async function requestFollowingRedirects(method, url, body, jar, maxHops = 10) {
91
95
  throw new Error(`Withings login exceeded ${maxHops} redirect hops starting from ${url}`);
92
96
  }
93
97
 
98
+ function buildResult(jar) {
99
+ const sessionTokenCookie = jar.findByPrefix('2fa_token_');
100
+ if (!sessionTokenCookie) {
101
+ throw new Error(
102
+ 'Withings session established but no 2fa_token_* cookie was found — the login flow may have changed.'
103
+ );
104
+ }
105
+
106
+ return {
107
+ cookieHeader: jar.toHeader(),
108
+ sessionToken: sessionTokenCookie.value,
109
+ sessionKey: jar.get('session_key') ?? null,
110
+ };
111
+ }
112
+
113
+ // Fast path: account.withings.com hands out a long-lived (~1 week) session_key
114
+ // cookie that, when replayed, skips straight past email/password/2FA entirely
115
+ // (redirects to /new_workflow/exit instead of asking for credentials at all).
116
+ // Confirmed empirically this doesn't need w_uuid alongside it. Throws if the
117
+ // session_key has expired/is invalid, signaling the caller to fall back to login().
118
+ async function resumeSession(sessionKey, trustCookieName, trustCookieValue) {
119
+ const jar = new CookieJar();
120
+ jar.set(trustCookieName, trustCookieValue);
121
+ jar.set('session_key', sessionKey);
122
+
123
+ const { finalUrl } = await requestFollowingRedirects(
124
+ 'GET',
125
+ `${ACCOUNT_BASE}/new_workflow/login`,
126
+ undefined,
127
+ jar
128
+ );
129
+
130
+ if (!finalUrl.includes('/new_workflow/exit')) {
131
+ throw new Error('Withings session_key did not resume a session (did not land on /new_workflow/exit)');
132
+ }
133
+
134
+ return buildResult(jar);
135
+ }
136
+
137
+ // Full fallback flow: email + password + trust cookie. Only needed the first
138
+ // time, or once the long-lived session_key from resumeSession() has actually
139
+ // expired — this is what Withings appears to throttle heavily if hit too
140
+ // often, so resumeSession() should always be tried first.
94
141
  async function login(email, password, trustCookieName, trustCookieValue) {
95
142
  const jar = new CookieJar();
96
- // This cookie (scoped to account.withings.com) is the "this device already passed
97
- // 2FA" marker — its name has stayed stable across many logins. It must be sent on
98
- // the very first request of a fresh login attempt, before email/password. Confirmed
99
- // empirically that no other cookie (e.g. w_uuid) is needed alongside it.
100
143
  jar.set(trustCookieName, trustCookieValue);
101
144
 
102
145
  await requestFollowingRedirects('GET', `${ACCOUNT_BASE}/`, undefined, jar);
@@ -126,17 +169,7 @@ async function login(email, password, trustCookieName, trustCookieValue) {
126
169
  );
127
170
  }
128
171
 
129
- const sessionTokenCookie = jar.findByPrefix('2fa_token_');
130
- if (!sessionTokenCookie) {
131
- throw new Error(
132
- 'Withings login completed but no 2fa_token_* cookie was found — the login flow may have changed.'
133
- );
134
- }
135
-
136
- return {
137
- cookieHeader: jar.toHeader(),
138
- sessionToken: sessionTokenCookie.value,
139
- };
172
+ return buildResult(jar);
140
173
  }
141
174
 
142
- module.exports = { login };
175
+ module.exports = { login, resumeSession };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-withings-environment-data",
3
- "version": "0.1.5",
3
+ "version": "0.3.1",
4
4
  "description": "Homebridge plugin exposing ambient CO2/air-quality and room temperature readings from a Withings WS-50 scale as HomeKit sensors",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -9,7 +9,14 @@
9
9
  "config.schema.json"
10
10
  ],
11
11
  "keywords": [
12
- "homebridge-plugin"
12
+ "homebridge-plugin",
13
+ "withings",
14
+ "ws-50",
15
+ "environment-data",
16
+ "temperature",
17
+ "co2",
18
+ "air-quality",
19
+ "homekit"
13
20
  ],
14
21
  "engines": {
15
22
  "homebridge": ">=1.6.0",