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 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.3.2
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, every successful poll
58
- publishes a message to topic `withingsenv/ws-50` on the configured
59
- broker, shaped like `{"temperature": 25.2, "co2_levels": 674}`. Messages
60
- aren't retained unless the Retain option is enabled. Publishing pauses
61
- once the data is stale (see Stale Data Warning Threshold below) and
62
- resumes once a fresh reading comes in.
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 / Retain**: see
91
- [Usage](#usage) above. Publishing is off by default; Port defaults to
92
- 1883. Username/Password are optional, for brokers that require auth.
93
- Retain is off by default.
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
@@ -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 !== true) {
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
- publishMqttReading() {
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
- const payload = JSON.stringify({ temperature: this.lastReading.temperature, co2_levels: this.lastReading.co2 });
256
- this.mqttClient.publish(MQTT_TOPIC, payload, { retain: this.config.mqttRetain === true });
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.publishMqttReading();
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.3.2",
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": [