homebridge-withings-environment-data 1.3.2 → 1.4.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 +23 -0
- package/README.md +21 -11
- package/config.schema.json +13 -0
- package/index.js +100 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## [1.4.0] - 2026-08-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `last_seen` field in the MQTT payload, reflecting the actual Withings
|
|
13
|
+
measurement time (not poll/publish time). Format configurable via a new
|
|
14
|
+
"Last Seen" dropdown: `ISO_8601` (default, UTC), `ISO_8601 local`,
|
|
15
|
+
`epoch` (milliseconds), or `disabled` to omit the field
|
|
16
|
+
- MQTT publishing now backfills every reading the scale buffered since the
|
|
17
|
+
last publish, not just the single newest one, published oldest first
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- MQTT Retain is now on by default (previously off by default)
|
|
21
|
+
|
|
22
|
+
## [1.3.3] - 2026-08-04
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Stale data now correctly shows a fault on the sensors' `StatusFault`
|
|
26
|
+
characteristic too, not just "No Response" in the Home app. A
|
|
27
|
+
successful poll always cleared the fault, even when the reading itself
|
|
28
|
+
was stale, so anything reading `StatusFault` directly instead of
|
|
29
|
+
calling the characteristic's get handler (e.g. Homebridge's own
|
|
30
|
+
accessory list) kept showing the sensors as fine
|
|
31
|
+
|
|
9
32
|
## [1.3.2] - 2026-08-04
|
|
10
33
|
|
|
11
34
|
### Added
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Withings Environment Data v1.
|
|
1
|
+
# Withings Environment Data v1.4.0
|
|
2
2
|
|
|
3
3
|
**This Homebridge plugin has been 100% vibe coded with Claude.**
|
|
4
4
|
|
|
@@ -54,12 +54,17 @@ turned on or off in Configuration:
|
|
|
54
54
|
- **Expose sensors as HomeKit Accessories** (default on): when off, the
|
|
55
55
|
plugin still polls Withings (and still publishes to MQTT, if enabled),
|
|
56
56
|
but doesn't create or update any HomeKit accessory.
|
|
57
|
-
- **Publish to MQTT** (default off): when on,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
- **Publish to MQTT** (default off): when on, publishes a message to topic
|
|
58
|
+
`withingsenv/ws-50` on the configured broker, shaped like
|
|
59
|
+
`{"temperature": 25.2, "co2_levels": 674, "last_seen":
|
|
60
|
+
"2026-08-18T20:30:00.000Z"}`, for every reading buffered by the scale
|
|
61
|
+
since the last publish, not just the newest — the WS-50 can take several
|
|
62
|
+
readings internally before it syncs, and this backfills that gap instead
|
|
63
|
+
of collapsing it into one message. Each is published in the order it was
|
|
64
|
+
actually recorded, oldest first. Messages are retained by default.
|
|
65
|
+
Publishing pauses entirely once the newest known reading is stale (see
|
|
66
|
+
Stale Data Warning Threshold below) and resumes (with any accumulated
|
|
67
|
+
backlog) once a fresh reading comes in.
|
|
63
68
|
|
|
64
69
|
### Configuration
|
|
65
70
|
|
|
@@ -87,10 +92,15 @@ Fields:
|
|
|
87
92
|
Anything above the Inferior boundary is reported as Poor.
|
|
88
93
|
- **Expose sensors as HomeKit Accessories**: see [Usage](#usage) above.
|
|
89
94
|
Default on.
|
|
90
|
-
- **Publish to MQTT / Host / Port / Username / Password /
|
|
91
|
-
[Usage](#usage) above. Publishing is off by default; Port
|
|
92
|
-
1883. Username/Password are optional, for brokers that
|
|
93
|
-
|
|
95
|
+
- **Publish to MQTT / Host / Port / Username / Password / Last Seen /
|
|
96
|
+
Retain**: see [Usage](#usage) above. Publishing is off by default; Port
|
|
97
|
+
defaults to 1883. Username/Password are optional, for brokers that
|
|
98
|
+
require auth. **Last Seen** controls the format of the `last_seen`
|
|
99
|
+
field, which always reflects the time the scale actually took the
|
|
100
|
+
measurement (not when the plugin polled or published it): `ISO_8601`
|
|
101
|
+
(default, UTC), `ISO_8601 local` (with UTC offset), `epoch`
|
|
102
|
+
(milliseconds), or `disabled` to omit the field entirely. Retain is on
|
|
103
|
+
by default.
|
|
94
104
|
- **Stale Data Warning Threshold (hours)**: if the newest reading from the
|
|
95
105
|
scale itself (not the plugin's poll) is older than this many hours — e.g.
|
|
96
106
|
nobody's stood on the scale in a while — a warning is logged on every
|
package/config.schema.json
CHANGED
|
@@ -106,6 +106,18 @@
|
|
|
106
106
|
"type": "password"
|
|
107
107
|
}
|
|
108
108
|
},
|
|
109
|
+
"mqttLastSeen": {
|
|
110
|
+
"title": "Last Seen",
|
|
111
|
+
"type": "string",
|
|
112
|
+
"default": "ISO_8601",
|
|
113
|
+
"oneOf": [
|
|
114
|
+
{ "title": "ISO_8601 (default)", "enum": ["ISO_8601"] },
|
|
115
|
+
{ "title": "ISO_8601 local", "enum": ["ISO_8601_local"] },
|
|
116
|
+
{ "title": "epoch (ms)", "enum": ["epoch"] },
|
|
117
|
+
{ "title": "disabled", "enum": ["disable"] }
|
|
118
|
+
],
|
|
119
|
+
"description": "Adds a last_seen field to the MQTT payload with the time of the newest Withings measurement (like zigbee2mqtt)."
|
|
120
|
+
},
|
|
109
121
|
"mqttRetain": {
|
|
110
122
|
"title": "Retain",
|
|
111
123
|
"type": "boolean",
|
|
@@ -158,6 +170,7 @@
|
|
|
158
170
|
"mqttPort",
|
|
159
171
|
"mqttUsername",
|
|
160
172
|
"mqttPassword",
|
|
173
|
+
"mqttLastSeen",
|
|
161
174
|
"mqttRetain"
|
|
162
175
|
]
|
|
163
176
|
},
|
package/index.js
CHANGED
|
@@ -68,9 +68,27 @@ async function fetchLatest({ cookieHeader, sessionToken, deviceId, userId }) {
|
|
|
68
68
|
// used to detect the scale itself going quiet (e.g. nobody's stood on
|
|
69
69
|
// it in a while), as opposed to the plugin failing to reach Withings.
|
|
70
70
|
readingDate: co2Point?.date ?? tempPoint?.date ?? null,
|
|
71
|
+
// Full backlog within the window (getmeashf — "high frequency measure"),
|
|
72
|
+
// not just the newest point — the scale can buffer multiple readings
|
|
73
|
+
// internally between cloud syncs. Only used for MQTT backfill; HomeKit
|
|
74
|
+
// characteristics only ever reflect the single newest point above.
|
|
75
|
+
co2Series,
|
|
76
|
+
tempSeries,
|
|
71
77
|
};
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
// Local-time ISO 8601 with UTC offset — Date's own toISOString() is always UTC.
|
|
81
|
+
function toLocalIso8601(date) {
|
|
82
|
+
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0');
|
|
83
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
84
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
85
|
+
return (
|
|
86
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
87
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
88
|
+
`${sign}${pad(offsetMinutes / 60)}:${pad(offsetMinutes % 60)}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
function mapCo2ToAirQuality(ppm, AirQuality, thresholds) {
|
|
75
93
|
if (ppm < thresholds.excellentMaxPpm) return AirQuality.EXCELLENT;
|
|
76
94
|
if (ppm < thresholds.goodMaxPpm) return AirQuality.GOOD;
|
|
@@ -122,6 +140,13 @@ class WithingsEnvironmentDataPlatform {
|
|
|
122
140
|
// it) so a restart can't be mistaken for "no reading yet" and wrongly clear
|
|
123
141
|
// an active stale-warning below.
|
|
124
142
|
this.lastReadingDate = state.lastReadingDate ?? null;
|
|
143
|
+
// Unix seconds of the newest reading already published to MQTT, used to
|
|
144
|
+
// backfill any points buffered by the scale since the last publish rather
|
|
145
|
+
// than only ever sending the single newest one. Defaults to the already-
|
|
146
|
+
// known lastReadingDate (if any) on first boot after upgrading to this
|
|
147
|
+
// feature, so existing installs don't replay their entire pre-existing
|
|
148
|
+
// history — only genuinely new backlog from here on.
|
|
149
|
+
this.lastPublishedMqttDate = state.lastPublishedMqttDate ?? state.lastReadingDate ?? null;
|
|
125
150
|
// The readingDate (unix seconds) we last sent a stale-data *notification* for,
|
|
126
151
|
// or null if not currently in a notified state. The log warning itself repeats
|
|
127
152
|
// every poll while data stays stale (like missed-poll-cycle failures do), but
|
|
@@ -152,6 +177,7 @@ class WithingsEnvironmentDataPlatform {
|
|
|
152
177
|
JSON.stringify({
|
|
153
178
|
sessionKey: this.sessionKey,
|
|
154
179
|
lastReadingDate: this.lastReadingDate,
|
|
180
|
+
lastPublishedMqttDate: this.lastPublishedMqttDate,
|
|
155
181
|
staleNotifiedForReadingDate: this.staleNotifiedForReadingDate,
|
|
156
182
|
})
|
|
157
183
|
);
|
|
@@ -239,7 +265,7 @@ class WithingsEnvironmentDataPlatform {
|
|
|
239
265
|
password: this.config.mqttPassword || undefined,
|
|
240
266
|
});
|
|
241
267
|
this.mqttClient.on('error', (err) => this.log.warn(`MQTT connection error: ${err.message}`));
|
|
242
|
-
if (this.config.mqttRetain
|
|
268
|
+
if (this.config.mqttRetain === false) {
|
|
243
269
|
// A publish with retain:false does NOT clear a previously-retained message —
|
|
244
270
|
// the broker keeps serving the last retained one until something explicitly
|
|
245
271
|
// clears it. Do that once per connect so turning Retain off actually stops
|
|
@@ -249,11 +275,67 @@ class WithingsEnvironmentDataPlatform {
|
|
|
249
275
|
this.api.on('shutdown', () => this.mqttClient.end());
|
|
250
276
|
}
|
|
251
277
|
|
|
252
|
-
|
|
278
|
+
// Config UI doesn't reliably apply schema defaults on boolean fields (see
|
|
279
|
+
// v1.3.2), so "on by default" is expressed here as "anything but an explicit
|
|
280
|
+
// false", not via a schema default.
|
|
281
|
+
getMqttRetain() {
|
|
282
|
+
return this.config.mqttRetain !== false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
getMqttLastSeenFormat() {
|
|
286
|
+
const valid = ['ISO_8601', 'ISO_8601_local', 'epoch', 'disable'];
|
|
287
|
+
return valid.includes(this.config.mqttLastSeen) ? this.config.mqttLastSeen : 'ISO_8601';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
formatLastSeen(readingDateUnixSeconds) {
|
|
291
|
+
const format = this.getMqttLastSeenFormat();
|
|
292
|
+
if (format === 'disable' || readingDateUnixSeconds === null || readingDateUnixSeconds === undefined) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const date = new Date(readingDateUnixSeconds * 1000);
|
|
297
|
+
if (format === 'epoch') return date.getTime();
|
|
298
|
+
if (format === 'ISO_8601_local') return toLocalIso8601(date);
|
|
299
|
+
return date.toISOString();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Publishes every buffered point newer than the last one we published, oldest
|
|
303
|
+
// first, instead of only ever the single newest — so a gap where the scale
|
|
304
|
+
// synced a backlog of readings (e.g. after being offline) gets backfilled on
|
|
305
|
+
// the MQTT side rather than collapsed into one message. HomeKit is
|
|
306
|
+
// unaffected: it only ever shows the single newest value (see applyReading).
|
|
307
|
+
publishMqttReadings(co2Series, tempSeries) {
|
|
253
308
|
if (!this.mqttClient) return;
|
|
254
309
|
if (this.isDataStale()) return;
|
|
255
|
-
|
|
256
|
-
|
|
310
|
+
|
|
311
|
+
const byDate = new Map();
|
|
312
|
+
for (const point of co2Series) {
|
|
313
|
+
if (!byDate.has(point.date)) byDate.set(point.date, {});
|
|
314
|
+
byDate.get(point.date).co2 = point.value;
|
|
315
|
+
}
|
|
316
|
+
for (const point of tempSeries) {
|
|
317
|
+
if (!byDate.has(point.date)) byDate.set(point.date, {});
|
|
318
|
+
byDate.get(point.date).temperature = point.value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const unpublished = [...byDate.entries()]
|
|
322
|
+
.filter(([date]) => this.lastPublishedMqttDate === null || date > this.lastPublishedMqttDate)
|
|
323
|
+
.sort(([a], [b]) => a - b);
|
|
324
|
+
|
|
325
|
+
for (const [date, reading] of unpublished) {
|
|
326
|
+
const payload = {};
|
|
327
|
+
if (reading.co2 !== undefined) payload.co2_levels = reading.co2;
|
|
328
|
+
if (reading.temperature !== undefined) payload.temperature = reading.temperature;
|
|
329
|
+
// The actual Withings measurement time, not when this poll ran or published.
|
|
330
|
+
const lastSeen = this.formatLastSeen(date);
|
|
331
|
+
if (lastSeen !== undefined) payload.last_seen = lastSeen;
|
|
332
|
+
this.mqttClient.publish(MQTT_TOPIC, JSON.stringify(payload), { retain: this.getMqttRetain() });
|
|
333
|
+
this.lastPublishedMqttDate = date;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (unpublished.length > 0) {
|
|
337
|
+
this.persistState();
|
|
338
|
+
}
|
|
257
339
|
}
|
|
258
340
|
|
|
259
341
|
setupServices(accessory) {
|
|
@@ -319,14 +401,14 @@ class WithingsEnvironmentDataPlatform {
|
|
|
319
401
|
}
|
|
320
402
|
}
|
|
321
403
|
|
|
322
|
-
const { co2, temperature, readingDate } = await fetchLatest({
|
|
404
|
+
const { co2, temperature, readingDate, co2Series, tempSeries } = await fetchLatest({
|
|
323
405
|
cookieHeader,
|
|
324
406
|
sessionToken,
|
|
325
407
|
deviceId: this.deviceId,
|
|
326
408
|
userId: this.userId,
|
|
327
409
|
});
|
|
328
410
|
|
|
329
|
-
this.applyReading(co2, temperature, readingDate);
|
|
411
|
+
this.applyReading(co2, temperature, readingDate, co2Series, tempSeries);
|
|
330
412
|
this.setFault(false);
|
|
331
413
|
this.missedCycles = 0;
|
|
332
414
|
this.hasNotifiedFailure = false;
|
|
@@ -354,6 +436,16 @@ class WithingsEnvironmentDataPlatform {
|
|
|
354
436
|
// quiet (no one's stood on it) while polls keep succeeding fine, so this
|
|
355
437
|
// is checked every cycle against whatever the newest reading actually is.
|
|
356
438
|
await this.checkStaleData();
|
|
439
|
+
|
|
440
|
+
// A successful poll above already cleared the fault via setFault(false),
|
|
441
|
+
// even though the reading it just fetched can itself be stale. Re-assert
|
|
442
|
+
// the fault in that case so anything reading StatusFault directly (e.g.
|
|
443
|
+
// Homebridge's own accessory list, which shows cached values rather than
|
|
444
|
+
// invoking the onGet handlers the way the Home app does) reflects
|
|
445
|
+
// staleness too, not just throwIfStale()'s "No Response" to HomeKit.
|
|
446
|
+
if (this.isDataStale()) {
|
|
447
|
+
this.setFault(true);
|
|
448
|
+
}
|
|
357
449
|
}
|
|
358
450
|
|
|
359
451
|
async sendNtfyNotification(message, title = 'Homebridge: Getting Withings Environment Data Failed!') {
|
|
@@ -471,7 +563,7 @@ class WithingsEnvironmentDataPlatform {
|
|
|
471
563
|
return this.lastReading.temperature;
|
|
472
564
|
}
|
|
473
565
|
|
|
474
|
-
applyReading(co2, temperature, readingDate) {
|
|
566
|
+
applyReading(co2, temperature, readingDate, co2Series = [], tempSeries = []) {
|
|
475
567
|
const co2Threshold = this.getCo2Threshold();
|
|
476
568
|
const hasReading = (co2 !== null && co2 !== undefined) || (temperature !== null && temperature !== undefined);
|
|
477
569
|
|
|
@@ -516,7 +608,7 @@ class WithingsEnvironmentDataPlatform {
|
|
|
516
608
|
}
|
|
517
609
|
|
|
518
610
|
if (hasReading) {
|
|
519
|
-
this.
|
|
611
|
+
this.publishMqttReadings(co2Series, tempSeries);
|
|
520
612
|
}
|
|
521
613
|
}
|
|
522
614
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-withings-environment-data",
|
|
3
3
|
"displayName": "Withings Environment Data",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.0",
|
|
5
5
|
"description": "Homebridge plugin exposing ambient CO2/air-quality and room temperature readings from a Withings WS-50 scale as HomeKit sensors",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|