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 +26 -5
- package/config.schema.json +7 -0
- package/index.js +136 -12
- package/lib/login.js +49 -16
- package/package.json +6 -1
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
166
|
-
// poll
|
|
167
|
-
|
|
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
|
-
|
|
251
|
+
getCo2Threshold() {
|
|
173
252
|
const threshold = Number(this.config.co2DetectedThresholdPpm);
|
|
174
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
],
|