homebridge-withings-environment-data 1.1.2 → 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,17 @@ 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
+
9
20
  ## [1.1.2] - 2026-07-28
10
21
 
11
22
  ### Added
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Homebridge Withings Environment Data v1.1.2
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
@@ -240,14 +254,14 @@ class WithingsEnvironmentDataPlatform {
240
254
  }
241
255
  }
242
256
 
243
- const { co2, temperature } = await fetchLatest({
257
+ const { co2, temperature, readingDate } = await fetchLatest({
244
258
  cookieHeader,
245
259
  sessionToken,
246
260
  deviceId: this.deviceId,
247
261
  userId: this.userId,
248
262
  });
249
263
 
250
- this.applyReading(co2, temperature);
264
+ this.applyReading(co2, temperature, readingDate);
251
265
  this.setFault(false);
252
266
  this.missedCycles = 0;
253
267
  this.hasNotifiedFailure = false;
@@ -270,17 +284,22 @@ class WithingsEnvironmentDataPlatform {
270
284
  await this.sendNtfyNotification(notificationMessage);
271
285
  }
272
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();
273
292
  }
274
293
 
275
- async sendNtfyNotification(errorMessage) {
294
+ async sendNtfyNotification(message, title = 'Homebridge: Getting Withings Environment Data Failed!') {
276
295
  const topic = this.config.ntfyTopic;
277
296
  if (!topic) return;
278
297
 
279
298
  try {
280
299
  await fetch(`https://ntfy.sh/${encodeURIComponent(topic)}`, {
281
300
  method: 'POST',
282
- headers: { Title: 'Homebridge: Getting Withings Environment Data Failed!' },
283
- body: errorMessage,
301
+ headers: { Title: title },
302
+ body: message,
284
303
  });
285
304
  } catch (err) {
286
305
  this.log.warn(`Failed to send ntfy notification: ${err.message}`);
@@ -310,8 +329,47 @@ class WithingsEnvironmentDataPlatform {
310
329
  };
311
330
  }
312
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
+
313
371
  isStale() {
314
- return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold();
372
+ return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold() || this.isDataStale();
315
373
  }
316
374
 
317
375
  throwIfStale() {
@@ -342,8 +400,20 @@ class WithingsEnvironmentDataPlatform {
342
400
  return this.lastReading.temperature;
343
401
  }
344
402
 
345
- applyReading(co2, temperature) {
403
+ applyReading(co2, temperature, readingDate) {
346
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
+ }
347
417
 
348
418
  if (co2 !== null && co2 !== undefined) {
349
419
  this.lastReading.co2 = co2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-withings-environment-data",
3
- "version": "1.1.2",
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": [