homebridge-withings-environment-data 1.1.1 → 1.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/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## [1.2.0] - 2026-07-31
10
+
11
+ ### Added
12
+ - Stale data detection: if the newest reading from the scale itself is
13
+ older than the new `staleDataWarningThresholdHours` config field
14
+ (default 4 hours), a warning is logged with the last-recorded date and
15
+ time, the sensors report "No Response" in the Home app, and (if `ntfy
16
+ Topic` is set) a notification is sent — all once per stale streak, with
17
+ a log line and reset once a fresher reading comes in. Separate from
18
+ poll failures, which were already covered.
19
+
20
+ ## [1.1.2] - 2026-07-28
21
+
22
+ ### Added
23
+ - A log entry after every successful full login stating whether a new
24
+ session token was cached, was unchanged, or wasn't issued at all
25
+
26
+ ### Fixed
27
+ - `login()`'s fallback full login now requires actually landing on
28
+ `/new_workflow/exit` to be considered successful (matching
29
+ `resumeSession()`'s existing check), instead of only checking for the
30
+ `confirm_totp` 2FA-trust failure case. Previously, any other silent
31
+ login failure (e.g. rate limiting) was treated as success, only
32
+ surfacing later as a confusing "Invalid Session" error from the
33
+ measure endpoint instead of a clear, correctly-classified
34
+ authentication failure
35
+
9
36
  ## [1.1.1] - 2026-07-27
10
37
 
11
38
  ### Fixed
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Homebridge Withings Environment Data v1.1.1
1
+ # Homebridge Withings Environment Data v1.2.0
2
2
 
3
3
  **This Homebridge plugin has been 100% vibe coded with Claude.**
4
4
 
@@ -72,11 +72,17 @@ Fields:
72
72
  - **Air Quality: Excellent/Good/Fair/Inferior below (ppm)**: the four ppm
73
73
  boundaries the AirQualitySensor category is based on (defaults 800, 1000,
74
74
  1500, 2000).
75
+ - **Stale Data Warning Threshold (hours)**: if the newest reading from the
76
+ scale itself (not the plugin's poll) is older than this many hours — e.g.
77
+ nobody's stood on the scale in a while — a warning is logged, the sensors
78
+ show "No Response" in the Home app, and (if ntfy Topic is set) a
79
+ notification is sent. Default 4. This is separate from poll failures.
75
80
  - **ntfy Topic (optional)**: if set, sends a push notification via
76
81
  [ntfy.sh](https://ntfy.sh) to this topic the first time a poll fails
77
82
  (not repeated on every subsequent failure in the same streak; only once
78
83
  a poll succeeds again does the next failure trigger a fresh
79
- notification). Leave blank to disable.
84
+ notification), and separately, once when the data becomes stale (see
85
+ above). Leave blank to disable.
80
86
 
81
87
  ### How authentication works
82
88
 
@@ -84,6 +84,13 @@
84
84
  "minimum": 0,
85
85
  "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."
86
86
  },
87
+ "staleDataWarningThresholdHours": {
88
+ "title": "Stale Data Warning Threshold (hours)",
89
+ "type": "integer",
90
+ "default": 4,
91
+ "minimum": 1,
92
+ "description": "If the newest CO2/temperature reading from the scale itself is older than this many hours (e.g. nobody's stood on it), log a warning, show \"No Response\" in the Home app, and (if ntfy Topic is set) send a notification. This is separate from poll failures above."
93
+ },
87
94
  "ntfyTopic": {
88
95
  "title": "ntfy Topic (optional)",
89
96
  "type": "string",
package/index.js CHANGED
@@ -55,11 +55,17 @@ async function fetchLatest({ cookieHeader, sessionToken, deviceId, userId }) {
55
55
 
56
56
  const co2Series = json.body.series?.find((s) => s.type === MEASTYPE_CO2)?.data ?? [];
57
57
  const tempSeries = json.body.series?.find((s) => s.type === MEASTYPE_TEMPERATURE)?.data ?? [];
58
+ // Both series are newest-first, so the first entry is the latest reading.
59
+ const co2Point = co2Series.length > 0 ? co2Series[0] : null;
60
+ const tempPoint = tempSeries.length > 0 ? tempSeries[0] : null;
58
61
 
59
62
  return {
60
- // Both series are newest-first, so the first entry is the latest reading.
61
- co2: co2Series.length > 0 ? co2Series[0].value : null,
62
- temperature: tempSeries.length > 0 ? tempSeries[0].value : null,
63
+ co2: co2Point ? co2Point.value : null,
64
+ temperature: tempPoint ? tempPoint.value : null,
65
+ // Unix seconds the reading was actually taken, not when we fetched it —
66
+ // used to detect the scale itself going quiet (e.g. nobody's stood on
67
+ // it in a while), as opposed to the plugin failing to reach Withings.
68
+ readingDate: co2Point?.date ?? tempPoint?.date ?? null,
63
69
  };
64
70
  }
65
71
 
@@ -97,6 +103,14 @@ class WithingsEnvironmentDataPlatform {
97
103
  // missed poll — reset once a poll succeeds again.
98
104
  this.hasNotifiedFailure = false;
99
105
 
106
+ // Unix seconds of the newest reading actually seen so far (from the
107
+ // Withings measurement itself, not from a successful poll) — used to
108
+ // detect the scale going quiet rather than the plugin failing to poll.
109
+ // Warn/notify once per stale streak, reset once a fresher reading comes in.
110
+ this.lastReadingDate = null;
111
+ this.staleDataWarned = false;
112
+ this.warnedMissingReadingDate = false;
113
+
100
114
  // The long-lived (~1 week) session_key that lets us skip email/password/2FA
101
115
  // entirely on most polls — see lib/login.js's resumeSession(). Persisted to
102
116
  // disk so it survives Homebridge restarts, and only a full login() refreshes
@@ -144,10 +158,16 @@ class WithingsEnvironmentDataPlatform {
144
158
  this.config.trustCookieValue
145
159
  );
146
160
 
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.');
161
+ if (result.sessionKey) {
162
+ if (result.sessionKey !== this.sessionKey) {
163
+ this.sessionKey = result.sessionKey;
164
+ this.saveSessionKey(result.sessionKey);
165
+ this.log.info('Withings full login succeeded; cached the new session for future polls.');
166
+ } else {
167
+ this.log.info('Withings full login succeeded; session token unchanged.');
168
+ }
169
+ } else {
170
+ this.log.warn('Withings full login succeeded but no new session token was issued; will retry full login next poll.');
151
171
  }
152
172
 
153
173
  return result;
@@ -234,14 +254,14 @@ class WithingsEnvironmentDataPlatform {
234
254
  }
235
255
  }
236
256
 
237
- const { co2, temperature } = await fetchLatest({
257
+ const { co2, temperature, readingDate } = await fetchLatest({
238
258
  cookieHeader,
239
259
  sessionToken,
240
260
  deviceId: this.deviceId,
241
261
  userId: this.userId,
242
262
  });
243
263
 
244
- this.applyReading(co2, temperature);
264
+ this.applyReading(co2, temperature, readingDate);
245
265
  this.setFault(false);
246
266
  this.missedCycles = 0;
247
267
  this.hasNotifiedFailure = false;
@@ -264,17 +284,22 @@ class WithingsEnvironmentDataPlatform {
264
284
  await this.sendNtfyNotification(notificationMessage);
265
285
  }
266
286
  }
287
+
288
+ // Independent of whether this poll itself succeeded — the scale can go
289
+ // quiet (no one's stood on it) while polls keep succeeding fine, so this
290
+ // is checked every cycle against whatever the newest reading actually is.
291
+ await this.checkStaleData();
267
292
  }
268
293
 
269
- async sendNtfyNotification(errorMessage) {
294
+ async sendNtfyNotification(message, title = 'Homebridge: Getting Withings Environment Data Failed!') {
270
295
  const topic = this.config.ntfyTopic;
271
296
  if (!topic) return;
272
297
 
273
298
  try {
274
299
  await fetch(`https://ntfy.sh/${encodeURIComponent(topic)}`, {
275
300
  method: 'POST',
276
- headers: { Title: 'Homebridge: Getting Withings Environment Data Failed!' },
277
- body: errorMessage,
301
+ headers: { Title: title },
302
+ body: message,
278
303
  });
279
304
  } catch (err) {
280
305
  this.log.warn(`Failed to send ntfy notification: ${err.message}`);
@@ -304,8 +329,47 @@ class WithingsEnvironmentDataPlatform {
304
329
  };
305
330
  }
306
331
 
332
+ getStaleDataThresholdHours() {
333
+ const threshold = Number(this.config.staleDataWarningThresholdHours);
334
+ return Number.isFinite(threshold) && threshold >= 1 ? threshold : 4;
335
+ }
336
+
337
+ // dd-mm and 24hr time, per the configured warning's required format — no
338
+ // year, since this is about "how long ago", not a historical record.
339
+ formatStaleDateTime(unixSeconds) {
340
+ const d = new Date(unixSeconds * 1000);
341
+ const pad = (n) => String(n).padStart(2, '0');
342
+ return {
343
+ date: `${pad(d.getDate())}-${pad(d.getMonth() + 1)}`,
344
+ time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
345
+ };
346
+ }
347
+
348
+ isDataStale() {
349
+ if (!this.lastReadingDate) return false;
350
+ const ageHours = (Date.now() / 1000 - this.lastReadingDate) / 3600;
351
+ return ageHours > this.getStaleDataThresholdHours();
352
+ }
353
+
354
+ async checkStaleData() {
355
+ const stale = this.isDataStale();
356
+
357
+ if (stale && !this.staleDataWarned) {
358
+ this.staleDataWarned = true;
359
+ const { date, time } = this.formatStaleDateTime(this.lastReadingDate);
360
+ this.log.warn(`Withings data is stale: last recorded at ${date} ${time}`);
361
+ await this.sendNtfyNotification(
362
+ `Temperature and/or CO2 readings haven't been updated since ${date} at ${time}.`,
363
+ 'Homebridge: Withings Environment Data is out of date'
364
+ );
365
+ } else if (!stale && this.staleDataWarned) {
366
+ this.staleDataWarned = false;
367
+ this.log.info('Withings: fresh data has been recorded.');
368
+ }
369
+ }
370
+
307
371
  isStale() {
308
- return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold();
372
+ return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold() || this.isDataStale();
309
373
  }
310
374
 
311
375
  throwIfStale() {
@@ -336,8 +400,20 @@ class WithingsEnvironmentDataPlatform {
336
400
  return this.lastReading.temperature;
337
401
  }
338
402
 
339
- applyReading(co2, temperature) {
403
+ applyReading(co2, temperature, readingDate) {
340
404
  const co2Threshold = this.getCo2Threshold();
405
+ const hasReading = (co2 !== null && co2 !== undefined) || (temperature !== null && temperature !== undefined);
406
+
407
+ if (hasReading && (readingDate === null || readingDate === undefined)) {
408
+ if (!this.warnedMissingReadingDate) {
409
+ this.warnedMissingReadingDate = true;
410
+ this.log.warn(
411
+ 'Withings measurement data has no recognizable date field; stale data detection is disabled until this is fixed.'
412
+ );
413
+ }
414
+ } else if (readingDate !== null && readingDate !== undefined) {
415
+ this.lastReadingDate = readingDate;
416
+ }
341
417
 
342
418
  if (co2 !== null && co2 !== undefined) {
343
419
  this.lastReading.co2 = co2;
package/lib/login.js CHANGED
@@ -169,6 +169,13 @@ async function login(email, password, trustCookieName, trustCookieValue) {
169
169
  );
170
170
  }
171
171
 
172
+ if (!finalUrl.includes('/new_workflow/exit')) {
173
+ throw new Error(
174
+ `Withings login did not complete (landed on ${finalUrl} instead of /new_workflow/exit) — ` +
175
+ 'this can happen if Withings is rate-limiting login attempts, or if the login flow changed.'
176
+ );
177
+ }
178
+
172
179
  return buildResult(jar);
173
180
  }
174
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-withings-environment-data",
3
- "version": "1.1.1",
3
+ "version": "1.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
6
  "files": [