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 +29 -5
- package/config.schema.json +12 -0
- package/index.js +160 -12
- package/lib/login.js +49 -16
- package/package.json +9 -2
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
166
|
-
// poll
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|