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 +11 -0
- package/README.md +8 -2
- package/config.schema.json +7 -0
- package/index.js +80 -10
- package/package.json +1 -1
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
|
+
# 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)
|
|
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
|
|
package/config.schema.json
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
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:
|
|
283
|
-
body:
|
|
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.
|
|
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": [
|