homebridge-withings-environment-data 0.1.4 → 0.2.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/README.md CHANGED
@@ -55,13 +55,34 @@ Fields:
55
55
  - **CO2 Detected Threshold (ppm)**: ppm above which the CarbonDioxideSensor
56
56
  reports "abnormal" (default 1000).
57
57
 
58
+ ## How authentication works
59
+
60
+ Logging in with email and password on every poll turned out to be a bad
61
+ idea in practice: Withings appears to throttle repeated automated logins
62
+ quite aggressively. Instead, the plugin reuses a long-lived (~1 week)
63
+ `session_key` that Withings' own web app relies on to stay logged in
64
+ without re-entering credentials each time.
65
+
66
+ That session is cached in a small file in Homebridge's own storage
67
+ directory, `withings-environment-data-session.json`, and reused across
68
+ polls and restarts. Email/password only get used as a fallback, the rare
69
+ times that cached session actually expires, and the fresh session that
70
+ fallback produces is automatically written back to the same file for next
71
+ time. In normal operation the plugin should hit the password endpoint very
72
+ infrequently, roughly weekly at most.
73
+
58
74
  ## When it stops working
59
75
 
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:
76
+ Most of the time this is self-healing: if the cached session has expired,
77
+ the plugin automatically falls back to a full login and caches the new
78
+ session it gets back, no action needed. A fault indicator appears on the
79
+ sensors during a failed poll, but the Home app keeps showing the last known
80
+ good reading rather than going blank.
81
+
82
+ If the Homebridge log instead shows a "session not trusted (landed on
83
+ confirm_totp)" error, the *trust cookie* itself has been invalidated (e.g.
84
+ after a password change, or Withings revoking trusted devices) — this is
85
+ what the fallback login relies on, so it can't self-heal on its own. Fix:
65
86
 
66
87
  1. Recapture the trust cookie: see [Getting the trust
67
88
  cookie](#getting-the-trust-cookie) below.
@@ -48,6 +48,13 @@
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."
51
58
  }
52
59
  }
53
60
  }
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,71 @@ 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
+
97
+ // The long-lived (~1 week) session_key that lets us skip email/password/2FA
98
+ // entirely on most polls — see lib/login.js's resumeSession(). Persisted to
99
+ // disk so it survives Homebridge restarts, and only a full login() refreshes
100
+ // it, since repeatedly hitting the password endpoint appears to be heavily
101
+ // throttled by Withings.
102
+ this.sessionStatePath = path.join(this.api.user.storagePath(), SESSION_STATE_FILENAME);
103
+ this.sessionKey = this.loadSessionKey();
104
+
84
105
  this.api.on('didFinishLaunching', () => this.discoverDevices());
85
106
  }
86
107
 
108
+ loadSessionKey() {
109
+ try {
110
+ const raw = fs.readFileSync(this.sessionStatePath, 'utf8');
111
+ return JSON.parse(raw).sessionKey ?? null;
112
+ } catch (err) {
113
+ if (err.code !== 'ENOENT') {
114
+ this.log.warn(`Could not read session state file: ${err.message}`);
115
+ }
116
+ return null;
117
+ }
118
+ }
119
+
120
+ saveSessionKey(sessionKey) {
121
+ try {
122
+ fs.writeFileSync(this.sessionStatePath, JSON.stringify({ sessionKey }));
123
+ } catch (err) {
124
+ this.log.warn(`Could not persist session state file: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ async authenticate() {
129
+ if (this.sessionKey) {
130
+ try {
131
+ return await resumeSession(this.sessionKey, this.config.trustCookieName, this.config.trustCookieValue);
132
+ } catch (err) {
133
+ this.log.warn(`Withings session resume failed, falling back to full login: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ const result = await login(
138
+ this.config.email,
139
+ this.config.password,
140
+ this.config.trustCookieName,
141
+ this.config.trustCookieValue
142
+ );
143
+
144
+ if (result.sessionKey && result.sessionKey !== this.sessionKey) {
145
+ this.sessionKey = result.sessionKey;
146
+ this.saveSessionKey(result.sessionKey);
147
+ this.log.info('Withings full login succeeded; cached the new session for future polls.');
148
+ }
149
+
150
+ return result;
151
+ }
152
+
87
153
  configureAccessory(accessory) {
88
154
  this.log.info('Loading accessory from cache:', accessory.displayName);
89
155
  this.accessories.push(accessory);
@@ -122,6 +188,19 @@ class WithingsEnvironmentDataPlatform {
122
188
  this.temperatureService =
123
189
  accessory.getService(this.Service.TemperatureSensor) ||
124
190
  accessory.addService(this.Service.TemperatureSensor, 'Temperature', 'temperature');
191
+
192
+ this.co2Service
193
+ .getCharacteristic(this.Characteristic.CarbonDioxideLevel)
194
+ .onGet(() => this.getCo2LevelOrThrow());
195
+ this.co2Service
196
+ .getCharacteristic(this.Characteristic.CarbonDioxideDetected)
197
+ .onGet(() => this.getCo2DetectedOrThrow());
198
+ this.airQualityService
199
+ .getCharacteristic(this.Characteristic.AirQuality)
200
+ .onGet(() => this.getAirQualityOrThrow());
201
+ this.temperatureService
202
+ .getCharacteristic(this.Characteristic.CurrentTemperature)
203
+ .onGet(() => this.getTemperatureOrThrow());
125
204
  }
126
205
 
127
206
  startPolling() {
@@ -134,12 +213,7 @@ class WithingsEnvironmentDataPlatform {
134
213
 
135
214
  async poll() {
136
215
  try {
137
- const { cookieHeader, sessionToken } = await login(
138
- this.config.email,
139
- this.config.password,
140
- this.config.trustCookieName,
141
- this.config.trustCookieValue
142
- );
216
+ const { cookieHeader, sessionToken } = await this.authenticate();
143
217
 
144
218
  if (!this.deviceId || !this.userId) {
145
219
  const discovered = await discoverDevice(cookieHeader);
@@ -160,20 +234,68 @@ class WithingsEnvironmentDataPlatform {
160
234
 
161
235
  this.applyReading(co2, temperature);
162
236
  this.setFault(false);
237
+ this.missedCycles = 0;
163
238
  } catch (err) {
164
239
  // 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}`);
240
+ // should keep showing the last known good reading, not go blank, on a
241
+ // single poll failure (e.g. the trust cookie expired and login needs
242
+ // recapturing). Only once missedCycles crosses the configured threshold
243
+ // do the onGet handlers below start throwing, which is what actually
244
+ // surfaces "No Response" in the Home app.
245
+ this.missedCycles += 1;
246
+ this.log.error(`Withings poll failed (missed cycle ${this.missedCycles}): ${err.message}`);
168
247
  this.setFault(true);
169
248
  }
170
249
  }
171
250
 
172
- applyReading(co2, temperature) {
251
+ getCo2Threshold() {
173
252
  const threshold = Number(this.config.co2DetectedThresholdPpm);
174
- const co2Threshold = Number.isFinite(threshold) && threshold > 0 ? threshold : 1000;
253
+ return Number.isFinite(threshold) && threshold > 0 ? threshold : 1000;
254
+ }
255
+
256
+ getNoResponseThreshold() {
257
+ const threshold = Number(this.config.noResponseAfterMissedPolls);
258
+ return Number.isFinite(threshold) && threshold >= 0 ? threshold : 2;
259
+ }
260
+
261
+ isStale() {
262
+ return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold();
263
+ }
264
+
265
+ throwIfStale() {
266
+ if (this.isStale()) {
267
+ throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
268
+ }
269
+ }
270
+
271
+ getCo2LevelOrThrow() {
272
+ this.throwIfStale();
273
+ return this.lastReading.co2;
274
+ }
275
+
276
+ getCo2DetectedOrThrow() {
277
+ this.throwIfStale();
278
+ return this.lastReading.co2 > this.getCo2Threshold()
279
+ ? this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_ABNORMAL
280
+ : this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_NORMAL;
281
+ }
282
+
283
+ getAirQualityOrThrow() {
284
+ this.throwIfStale();
285
+ return mapCo2ToAirQuality(this.lastReading.co2, this.Characteristic.AirQuality);
286
+ }
287
+
288
+ getTemperatureOrThrow() {
289
+ this.throwIfStale();
290
+ return this.lastReading.temperature;
291
+ }
292
+
293
+ applyReading(co2, temperature) {
294
+ const co2Threshold = this.getCo2Threshold();
175
295
 
176
296
  if (co2 !== null && co2 !== undefined) {
297
+ this.lastReading.co2 = co2;
298
+ this.hasEverSucceeded = true;
177
299
  this.co2Service.updateCharacteristic(this.Characteristic.CarbonDioxideLevel, co2);
178
300
  this.co2Service.updateCharacteristic(
179
301
  this.Characteristic.CarbonDioxideDetected,
@@ -188,6 +310,8 @@ class WithingsEnvironmentDataPlatform {
188
310
  }
189
311
 
190
312
  if (temperature !== null && temperature !== undefined) {
313
+ this.lastReading.temperature = temperature;
314
+ this.hasEverSucceeded = true;
191
315
  this.temperatureService.updateCharacteristic(this.Characteristic.CurrentTemperature, temperature);
192
316
  }
193
317
  }
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,8 +1,13 @@
1
1
  {
2
2
  "name": "homebridge-withings-environment-data",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
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
+ "files": [
7
+ "index.js",
8
+ "lib",
9
+ "config.schema.json"
10
+ ],
6
11
  "keywords": [
7
12
  "homebridge-plugin"
8
13
  ],