homebridge-withings-environment-data 1.1.2 → 1.2.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/CHANGELOG.md +27 -0
- package/README.md +9 -2
- package/config.schema.json +7 -0
- package/index.js +110 -17
- package/package.json +1 -1
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.1] - 2026-08-02
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- The stale-data log warning now logs on every poll while data remains
|
|
13
|
+
stale (matching how missed poll cycles are already logged), instead of
|
|
14
|
+
only once per stale streak.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- The stale-data ntfy notification no longer re-fires every time
|
|
18
|
+
Homebridge restarts while the same stale reading is still the newest one
|
|
19
|
+
on record. The "already notified about this" state, and the last-known
|
|
20
|
+
reading date it applies to, are now both persisted to disk instead of
|
|
21
|
+
living only in memory (a first fix persisted only the former, which
|
|
22
|
+
still misfired if the first poll after a restart happened to fail), and
|
|
23
|
+
still reset correctly once a fresher reading comes in.
|
|
24
|
+
|
|
25
|
+
## [1.2.0] - 2026-07-31
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Stale data detection: if the newest reading from the scale itself is
|
|
29
|
+
older than the new `staleDataWarningThresholdHours` config field
|
|
30
|
+
(default 4 hours), a warning is logged with the last-recorded date and
|
|
31
|
+
time, the sensors report "No Response" in the Home app, and (if `ntfy
|
|
32
|
+
Topic` is set) a notification is sent — all once per stale streak, with
|
|
33
|
+
a log line and reset once a fresher reading comes in. Separate from
|
|
34
|
+
poll failures, which were already covered.
|
|
35
|
+
|
|
9
36
|
## [1.1.2] - 2026-07-28
|
|
10
37
|
|
|
11
38
|
### Added
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Homebridge Withings Environment Data v1.1
|
|
1
|
+
# Homebridge Withings Environment Data v1.2.1
|
|
2
2
|
|
|
3
3
|
**This Homebridge plugin has been 100% vibe coded with Claude.**
|
|
4
4
|
|
|
@@ -72,11 +72,18 @@ 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 on every
|
|
78
|
+
poll for as long as it stays stale, the sensors show "No Response" in the
|
|
79
|
+
Home app, and (if ntfy Topic is set) a single notification is sent for
|
|
80
|
+
that stale reading. Default 4. This is separate from poll failures.
|
|
75
81
|
- **ntfy Topic (optional)**: if set, sends a push notification via
|
|
76
82
|
[ntfy.sh](https://ntfy.sh) to this topic the first time a poll fails
|
|
77
83
|
(not repeated on every subsequent failure in the same streak; only once
|
|
78
84
|
a poll succeeds again does the next failure trigger a fresh
|
|
79
|
-
notification)
|
|
85
|
+
notification), and separately, once when the data becomes stale (see
|
|
86
|
+
above). Leave blank to disable.
|
|
80
87
|
|
|
81
88
|
### How authentication works
|
|
82
89
|
|
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,32 +103,56 @@ class WithingsEnvironmentDataPlatform {
|
|
|
97
103
|
// missed poll — reset once a poll succeeds again.
|
|
98
104
|
this.hasNotifiedFailure = false;
|
|
99
105
|
|
|
106
|
+
this.warnedMissingReadingDate = false;
|
|
107
|
+
|
|
100
108
|
// The long-lived (~1 week) session_key that lets us skip email/password/2FA
|
|
101
109
|
// entirely on most polls — see lib/login.js's resumeSession(). Persisted to
|
|
102
110
|
// disk so it survives Homebridge restarts, and only a full login() refreshes
|
|
103
111
|
// it, since repeatedly hitting the password endpoint appears to be heavily
|
|
104
112
|
// throttled by Withings.
|
|
105
113
|
this.sessionStatePath = path.join(this.api.user.storagePath(), SESSION_STATE_FILENAME);
|
|
106
|
-
|
|
114
|
+
const state = this.loadPersistedState();
|
|
115
|
+
this.sessionKey = state.sessionKey ?? null;
|
|
116
|
+
// Unix seconds of the newest reading actually seen so far (from the Withings
|
|
117
|
+
// measurement itself, not from a successful poll) — used to detect the scale
|
|
118
|
+
// going quiet rather than the plugin failing to poll. Persisted and restored
|
|
119
|
+
// immediately on boot (rather than waiting on the first poll to repopulate
|
|
120
|
+
// it) so a restart can't be mistaken for "no reading yet" and wrongly clear
|
|
121
|
+
// an active stale-warning below.
|
|
122
|
+
this.lastReadingDate = state.lastReadingDate ?? null;
|
|
123
|
+
// The readingDate (unix seconds) we last sent a stale-data *notification* for,
|
|
124
|
+
// or null if not currently in a notified state. The log warning itself repeats
|
|
125
|
+
// every poll while data stays stale (like missed-poll-cycle failures do), but
|
|
126
|
+
// this gates the ntfy notification to once per distinct stale reading, and is
|
|
127
|
+
// persisted so a Homebridge restart doesn't re-notify about a reading we've
|
|
128
|
+
// already notified about — it only resets once a fresher reading comes in.
|
|
129
|
+
this.staleNotifiedForReadingDate = state.staleNotifiedForReadingDate ?? null;
|
|
107
130
|
|
|
108
131
|
this.api.on('didFinishLaunching', () => this.discoverDevices());
|
|
109
132
|
}
|
|
110
133
|
|
|
111
|
-
|
|
134
|
+
loadPersistedState() {
|
|
112
135
|
try {
|
|
113
136
|
const raw = fs.readFileSync(this.sessionStatePath, 'utf8');
|
|
114
|
-
return JSON.parse(raw)
|
|
137
|
+
return JSON.parse(raw);
|
|
115
138
|
} catch (err) {
|
|
116
139
|
if (err.code !== 'ENOENT') {
|
|
117
140
|
this.log.warn(`Could not read session state file: ${err.message}`);
|
|
118
141
|
}
|
|
119
|
-
return
|
|
142
|
+
return {};
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
145
|
|
|
123
|
-
|
|
146
|
+
persistState() {
|
|
124
147
|
try {
|
|
125
|
-
fs.writeFileSync(
|
|
148
|
+
fs.writeFileSync(
|
|
149
|
+
this.sessionStatePath,
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
sessionKey: this.sessionKey,
|
|
152
|
+
lastReadingDate: this.lastReadingDate,
|
|
153
|
+
staleNotifiedForReadingDate: this.staleNotifiedForReadingDate,
|
|
154
|
+
})
|
|
155
|
+
);
|
|
126
156
|
} catch (err) {
|
|
127
157
|
this.log.warn(`Could not persist session state file: ${err.message}`);
|
|
128
158
|
}
|
|
@@ -147,7 +177,7 @@ class WithingsEnvironmentDataPlatform {
|
|
|
147
177
|
if (result.sessionKey) {
|
|
148
178
|
if (result.sessionKey !== this.sessionKey) {
|
|
149
179
|
this.sessionKey = result.sessionKey;
|
|
150
|
-
this.
|
|
180
|
+
this.persistState();
|
|
151
181
|
this.log.info('Withings full login succeeded; cached the new session for future polls.');
|
|
152
182
|
} else {
|
|
153
183
|
this.log.info('Withings full login succeeded; session token unchanged.');
|
|
@@ -240,14 +270,14 @@ class WithingsEnvironmentDataPlatform {
|
|
|
240
270
|
}
|
|
241
271
|
}
|
|
242
272
|
|
|
243
|
-
const { co2, temperature } = await fetchLatest({
|
|
273
|
+
const { co2, temperature, readingDate } = await fetchLatest({
|
|
244
274
|
cookieHeader,
|
|
245
275
|
sessionToken,
|
|
246
276
|
deviceId: this.deviceId,
|
|
247
277
|
userId: this.userId,
|
|
248
278
|
});
|
|
249
279
|
|
|
250
|
-
this.applyReading(co2, temperature);
|
|
280
|
+
this.applyReading(co2, temperature, readingDate);
|
|
251
281
|
this.setFault(false);
|
|
252
282
|
this.missedCycles = 0;
|
|
253
283
|
this.hasNotifiedFailure = false;
|
|
@@ -270,17 +300,22 @@ class WithingsEnvironmentDataPlatform {
|
|
|
270
300
|
await this.sendNtfyNotification(notificationMessage);
|
|
271
301
|
}
|
|
272
302
|
}
|
|
303
|
+
|
|
304
|
+
// Independent of whether this poll itself succeeded — the scale can go
|
|
305
|
+
// quiet (no one's stood on it) while polls keep succeeding fine, so this
|
|
306
|
+
// is checked every cycle against whatever the newest reading actually is.
|
|
307
|
+
await this.checkStaleData();
|
|
273
308
|
}
|
|
274
309
|
|
|
275
|
-
async sendNtfyNotification(
|
|
310
|
+
async sendNtfyNotification(message, title = 'Homebridge: Getting Withings Environment Data Failed!') {
|
|
276
311
|
const topic = this.config.ntfyTopic;
|
|
277
312
|
if (!topic) return;
|
|
278
313
|
|
|
279
314
|
try {
|
|
280
315
|
await fetch(`https://ntfy.sh/${encodeURIComponent(topic)}`, {
|
|
281
316
|
method: 'POST',
|
|
282
|
-
headers: { Title:
|
|
283
|
-
body:
|
|
317
|
+
headers: { Title: title },
|
|
318
|
+
body: message,
|
|
284
319
|
});
|
|
285
320
|
} catch (err) {
|
|
286
321
|
this.log.warn(`Failed to send ntfy notification: ${err.message}`);
|
|
@@ -310,8 +345,53 @@ class WithingsEnvironmentDataPlatform {
|
|
|
310
345
|
};
|
|
311
346
|
}
|
|
312
347
|
|
|
348
|
+
getStaleDataThresholdHours() {
|
|
349
|
+
const threshold = Number(this.config.staleDataWarningThresholdHours);
|
|
350
|
+
return Number.isFinite(threshold) && threshold >= 1 ? threshold : 4;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// dd-mm and 24hr time, per the configured warning's required format — no
|
|
354
|
+
// year, since this is about "how long ago", not a historical record.
|
|
355
|
+
formatStaleDateTime(unixSeconds) {
|
|
356
|
+
const d = new Date(unixSeconds * 1000);
|
|
357
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
358
|
+
return {
|
|
359
|
+
date: `${pad(d.getDate())}-${pad(d.getMonth() + 1)}`,
|
|
360
|
+
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
isDataStale() {
|
|
365
|
+
if (!this.lastReadingDate) return false;
|
|
366
|
+
const ageHours = (Date.now() / 1000 - this.lastReadingDate) / 3600;
|
|
367
|
+
return ageHours > this.getStaleDataThresholdHours();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async checkStaleData() {
|
|
371
|
+
const stale = this.isDataStale();
|
|
372
|
+
|
|
373
|
+
if (stale) {
|
|
374
|
+
const { date, time } = this.formatStaleDateTime(this.lastReadingDate);
|
|
375
|
+
this.log.warn(`Withings data is stale: last recorded at ${date} ${time}`);
|
|
376
|
+
|
|
377
|
+
const alreadyNotifiedForThisReading = this.staleNotifiedForReadingDate === this.lastReadingDate;
|
|
378
|
+
if (!alreadyNotifiedForThisReading) {
|
|
379
|
+
this.staleNotifiedForReadingDate = this.lastReadingDate;
|
|
380
|
+
this.persistState();
|
|
381
|
+
await this.sendNtfyNotification(
|
|
382
|
+
`Temperature and/or CO2 readings haven't been updated since ${date} at ${time}.`,
|
|
383
|
+
'Homebridge: Withings Environment Data is out of date'
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
} else if (this.staleNotifiedForReadingDate !== null) {
|
|
387
|
+
this.staleNotifiedForReadingDate = null;
|
|
388
|
+
this.persistState();
|
|
389
|
+
this.log.info('Withings: fresh data has been recorded.');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
313
393
|
isStale() {
|
|
314
|
-
return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold();
|
|
394
|
+
return !this.hasEverSucceeded || this.missedCycles > this.getNoResponseThreshold() || this.isDataStale();
|
|
315
395
|
}
|
|
316
396
|
|
|
317
397
|
throwIfStale() {
|
|
@@ -342,8 +422,21 @@ class WithingsEnvironmentDataPlatform {
|
|
|
342
422
|
return this.lastReading.temperature;
|
|
343
423
|
}
|
|
344
424
|
|
|
345
|
-
applyReading(co2, temperature) {
|
|
425
|
+
applyReading(co2, temperature, readingDate) {
|
|
346
426
|
const co2Threshold = this.getCo2Threshold();
|
|
427
|
+
const hasReading = (co2 !== null && co2 !== undefined) || (temperature !== null && temperature !== undefined);
|
|
428
|
+
|
|
429
|
+
if (hasReading && (readingDate === null || readingDate === undefined)) {
|
|
430
|
+
if (!this.warnedMissingReadingDate) {
|
|
431
|
+
this.warnedMissingReadingDate = true;
|
|
432
|
+
this.log.warn(
|
|
433
|
+
'Withings measurement data has no recognizable date field; stale data detection is disabled until this is fixed.'
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
} else if (readingDate !== null && readingDate !== undefined && readingDate !== this.lastReadingDate) {
|
|
437
|
+
this.lastReadingDate = readingDate;
|
|
438
|
+
this.persistState();
|
|
439
|
+
}
|
|
347
440
|
|
|
348
441
|
if (co2 !== null && co2 !== undefined) {
|
|
349
442
|
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
|
|
3
|
+
"version": "1.2.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": [
|