homebridge-withings-environment-data 1.3.3 → 1.5.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,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## [1.5.0] - 2026-08-19
10
+
11
+ ### Added
12
+ - "Send Stale Data Notification" checkbox (default on): lets the ntfy
13
+ notification for stale data be turned off independently of the log
14
+ warning and "No Response" in the Home app, which still happen either way
15
+ - Log line confirming a successful MQTT broker connection, and one per poll
16
+ reporting how many readings were published and over what time range
17
+ (previously only connection *errors* were logged, so a client that never
18
+ connected looked the same in the log as a working one)
19
+
20
+ ### Fixed
21
+ - MQTT backfill no longer gets stuck once the newest known reading is
22
+ itself still stale by the time it's fetched (e.g. the scale recovers
23
+ from an outage, but the newest synced point is still older than the
24
+ Stale Data Warning Threshold). Publishing is now gated purely on
25
+ whether a reading has already been sent, not on wall-clock staleness
26
+ - MQTT backfill no longer strands readings on the first run after
27
+ upgrading: the "last published" marker was seeded from the newest
28
+ reading the *poller* had seen rather than the newest actually
29
+ *published*, silently skipping everything in between
30
+
31
+ ## [1.4.0] - 2026-08-19
32
+
33
+ ### Added
34
+ - `last_seen` field in the MQTT payload, reflecting the actual Withings
35
+ measurement time (not poll/publish time). Format configurable via a new
36
+ "Last Seen" dropdown: `ISO_8601` (default, UTC), `ISO_8601 local`,
37
+ `epoch` (milliseconds), or `disabled` to omit the field
38
+ - MQTT publishing now backfills every reading the scale buffered since the
39
+ last publish, not just the single newest one, published oldest first
40
+
41
+ ### Changed
42
+ - MQTT Retain is now on by default (previously off by default)
43
+
9
44
  ## [1.3.3] - 2026-08-04
10
45
 
11
46
  ### Fixed
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Withings Environment Data v1.3.3
1
+ # Withings Environment Data v1.5.0
2
2
 
3
3
  **This Homebridge plugin has been 100% vibe coded with Claude.**
4
4
 
@@ -54,12 +54,18 @@ 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 only ever sends readings not already sent before (tracked
66
+ independently of HomeKit's staleness check below), so it keeps working
67
+ even if the newest available reading is itself still old by the time it
68
+ arrives.
63
69
 
64
70
  ### Configuration
65
71
 
@@ -87,16 +93,25 @@ Fields:
87
93
  Anything above the Inferior boundary is reported as Poor.
88
94
  - **Expose sensors as HomeKit Accessories**: see [Usage](#usage) above.
89
95
  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.
96
+ - **Publish to MQTT / Host / Port / Username / Password / Last Seen /
97
+ Retain**: see [Usage](#usage) above. Publishing is off by default; Port
98
+ defaults to 1883. Username/Password are optional, for brokers that
99
+ require auth. **Last Seen** controls the format of the `last_seen`
100
+ field, which always reflects the time the scale actually took the
101
+ measurement (not when the plugin polled or published it): `ISO_8601`
102
+ (default, UTC), `ISO_8601 local` (with UTC offset), `epoch`
103
+ (milliseconds), or `disabled` to omit the field entirely. Retain is on
104
+ by default.
94
105
  - **Stale Data Warning Threshold (hours)**: if the newest reading from the
95
106
  scale itself (not the plugin's poll) is older than this many hours — e.g.
96
107
  nobody's stood on the scale in a while — a warning is logged on every
97
- poll for as long as it stays stale, the sensors show "No Response" in the
98
- Home app, and (if ntfy Topic is set) a single notification is sent for
99
- that stale reading. Default 4. This is separate from poll failures.
108
+ poll for as long as it stays stale, and the sensors show "No Response" in
109
+ the Home app. Default 4. This is separate from poll failures.
110
+ - **Send Stale Data Notification** (default on): whether a stale reading
111
+ additionally sends a single ntfy notification (requires ntfy.sh
112
+ Notification Topic below). The log warning and "No Response" in the Home
113
+ app happen either way, so turn this off if the unresponsive sensor is
114
+ signal enough on its own.
100
115
  - **ntfy.sh Notification Topic** (optional): if set, sends a push notification via
101
116
  [ntfy.sh](https://ntfy.sh) to this topic the first time a poll fails
102
117
  (not repeated on every subsequent failure in the same streak; only once
@@ -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",
@@ -125,6 +137,11 @@
125
137
  "minimum": 1,
126
138
  "description": "If the newest CO2/temperature reading from the scale itself is older than this many hours, log a warning, show \"No Response\" in the Home app, and send a notification."
127
139
  },
140
+ "sendStaleDataNotification": {
141
+ "title": "Send Stale Data Notification",
142
+ "type": "boolean",
143
+ "description": "Send an ntfy notification when data goes stale (requires ntfy.sh Notification Topic below). The log warning and \"No Response\" in the Home app happen either way."
144
+ },
128
145
  "ntfyTopic": {
129
146
  "title": "ntfy.sh Notification Topic",
130
147
  "type": "string",
@@ -158,6 +175,7 @@
158
175
  "mqttPort",
159
176
  "mqttUsername",
160
177
  "mqttPassword",
178
+ "mqttLastSeen",
161
179
  "mqttRetain"
162
180
  ]
163
181
  },
@@ -177,6 +195,7 @@
177
195
  "pollIntervalMinutes",
178
196
  "noResponseAfterMissedPolls",
179
197
  "staleDataWarningThresholdHours",
198
+ "sendStaleDataNotification",
180
199
  "ntfyTopic"
181
200
  ]
182
201
  }
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,19 @@ 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.
146
+ //
147
+ // Deliberately NOT seeded from lastReadingDate when absent: that tracks the
148
+ // newest reading the *poller* has seen, which is not the same as the newest
149
+ // reading actually *published*. Seeding from it silently strands everything
150
+ // in between (found live: ~12h of readings were skipped on upgrade because
151
+ // lastReadingDate had moved on while MQTT publishing was blocked). Starting
152
+ // at null instead republishes at most one window on first run, which the
153
+ // watermark then dedups from — failing toward a duplicate rather than
154
+ // toward permanent data loss.
155
+ this.lastPublishedMqttDate = state.lastPublishedMqttDate ?? null;
125
156
  // The readingDate (unix seconds) we last sent a stale-data *notification* for,
126
157
  // or null if not currently in a notified state. The log warning itself repeats
127
158
  // every poll while data stays stale (like missed-poll-cycle failures do), but
@@ -152,6 +183,7 @@ class WithingsEnvironmentDataPlatform {
152
183
  JSON.stringify({
153
184
  sessionKey: this.sessionKey,
154
185
  lastReadingDate: this.lastReadingDate,
186
+ lastPublishedMqttDate: this.lastPublishedMqttDate,
155
187
  staleNotifiedForReadingDate: this.staleNotifiedForReadingDate,
156
188
  })
157
189
  );
@@ -239,7 +271,10 @@ class WithingsEnvironmentDataPlatform {
239
271
  password: this.config.mqttPassword || undefined,
240
272
  });
241
273
  this.mqttClient.on('error', (err) => this.log.warn(`MQTT connection error: ${err.message}`));
242
- if (this.config.mqttRetain !== true) {
274
+ // Without this, a silently-never-connecting client looks identical in the
275
+ // log to a working one, since only the error path said anything.
276
+ this.mqttClient.on('connect', () => this.log.info(`Connected to MQTT broker at ${url}.`));
277
+ if (this.config.mqttRetain === false) {
243
278
  // A publish with retain:false does NOT clear a previously-retained message —
244
279
  // the broker keeps serving the last retained one until something explicitly
245
280
  // clears it. Do that once per connect so turning Retain off actually stops
@@ -249,11 +284,86 @@ class WithingsEnvironmentDataPlatform {
249
284
  this.api.on('shutdown', () => this.mqttClient.end());
250
285
  }
251
286
 
252
- publishMqttReading() {
287
+ // Config UI doesn't reliably apply schema defaults on boolean fields (see
288
+ // v1.3.2), so "on by default" is expressed here as "anything but an explicit
289
+ // false", not via a schema default.
290
+ getMqttRetain() {
291
+ return this.config.mqttRetain !== false;
292
+ }
293
+
294
+ getMqttLastSeenFormat() {
295
+ const valid = ['ISO_8601', 'ISO_8601_local', 'epoch', 'disable'];
296
+ return valid.includes(this.config.mqttLastSeen) ? this.config.mqttLastSeen : 'ISO_8601';
297
+ }
298
+
299
+ formatLastSeen(readingDateUnixSeconds) {
300
+ const format = this.getMqttLastSeenFormat();
301
+ if (format === 'disable' || readingDateUnixSeconds === null || readingDateUnixSeconds === undefined) {
302
+ return undefined;
303
+ }
304
+
305
+ const date = new Date(readingDateUnixSeconds * 1000);
306
+ if (format === 'epoch') return date.getTime();
307
+ if (format === 'ISO_8601_local') return toLocalIso8601(date);
308
+ return date.toISOString();
309
+ }
310
+
311
+ // Publishes every buffered point newer than the last one we published, oldest
312
+ // first, instead of only ever the single newest — so a gap where the scale
313
+ // synced a backlog of readings (e.g. after being offline) gets backfilled on
314
+ // the MQTT side rather than collapsed into one message. HomeKit is
315
+ // unaffected: it only ever shows the single newest value (see applyReading).
316
+ //
317
+ // Deliberately NOT gated on isDataStale(): that reflects whether the newest
318
+ // known reading is old relative to *now*, which can still be true right
319
+ // after a real recovery (e.g. the scale's own buffered backlog syncs, but
320
+ // its newest point is still older than the threshold). Gating on that would
321
+ // strand genuinely new, never-before-published backlog until an even
322
+ // fresher point arrives later. Dedup below (lastPublishedMqttDate) already
323
+ // prevents ever republishing the same reading, which is the only thing this
324
+ // gate needs to guard against.
325
+ publishMqttReadings(co2Series, tempSeries) {
253
326
  if (!this.mqttClient) return;
254
- 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 });
327
+
328
+ const byDate = new Map();
329
+ for (const point of co2Series) {
330
+ if (!byDate.has(point.date)) byDate.set(point.date, {});
331
+ byDate.get(point.date).co2 = point.value;
332
+ }
333
+ for (const point of tempSeries) {
334
+ if (!byDate.has(point.date)) byDate.set(point.date, {});
335
+ byDate.get(point.date).temperature = point.value;
336
+ }
337
+
338
+ const unpublished = [...byDate.entries()]
339
+ .filter(([date]) => this.lastPublishedMqttDate === null || date > this.lastPublishedMqttDate)
340
+ .sort(([a], [b]) => a - b);
341
+
342
+ for (const [date, reading] of unpublished) {
343
+ const payload = {};
344
+ if (reading.co2 !== undefined) payload.co2_levels = reading.co2;
345
+ if (reading.temperature !== undefined) payload.temperature = reading.temperature;
346
+ // The actual Withings measurement time, not when this poll ran or published.
347
+ const lastSeen = this.formatLastSeen(date);
348
+ if (lastSeen !== undefined) payload.last_seen = lastSeen;
349
+ this.mqttClient.publish(MQTT_TOPIC, JSON.stringify(payload), { retain: this.getMqttRetain() });
350
+ this.lastPublishedMqttDate = date;
351
+ }
352
+
353
+ if (unpublished.length > 0) {
354
+ this.persistState();
355
+ const first = this.formatStaleDateTime(unpublished[0][0]);
356
+ const last = this.formatStaleDateTime(unpublished[unpublished.length - 1][0]);
357
+ const range =
358
+ unpublished.length === 1
359
+ ? `${first.date} ${first.time}`
360
+ : `${first.date} ${first.time} to ${last.date} ${last.time}`;
361
+ this.log.info(`Published ${unpublished.length} reading(s) to MQTT (${range}).`);
362
+ } else {
363
+ // Distinguishes "nothing new to send" from "never connected/never tried",
364
+ // which otherwise look the same from the log alone.
365
+ this.log.debug('No new readings to publish to MQTT; nothing newer than the last published reading.');
366
+ }
257
367
  }
258
368
 
259
369
  setupServices(accessory) {
@@ -319,14 +429,14 @@ class WithingsEnvironmentDataPlatform {
319
429
  }
320
430
  }
321
431
 
322
- const { co2, temperature, readingDate } = await fetchLatest({
432
+ const { co2, temperature, readingDate, co2Series, tempSeries } = await fetchLatest({
323
433
  cookieHeader,
324
434
  sessionToken,
325
435
  deviceId: this.deviceId,
326
436
  userId: this.userId,
327
437
  });
328
438
 
329
- this.applyReading(co2, temperature, readingDate);
439
+ this.applyReading(co2, temperature, readingDate, co2Series, tempSeries);
330
440
  this.setFault(false);
331
441
  this.missedCycles = 0;
332
442
  this.hasNotifiedFailure = false;
@@ -404,6 +514,12 @@ class WithingsEnvironmentDataPlatform {
404
514
  };
405
515
  }
406
516
 
517
+ // Same Config UI boolean-default rendering quirk as mqttRetain (see
518
+ // getMqttRetain), so "on by default" is a runtime default, not a schema one.
519
+ getSendStaleDataNotification() {
520
+ return this.config.sendStaleDataNotification !== false;
521
+ }
522
+
407
523
  getStaleDataThresholdHours() {
408
524
  const threshold = Number(this.config.staleDataWarningThresholdHours);
409
525
  return Number.isFinite(threshold) && threshold >= 1 ? threshold : 4;
@@ -437,10 +553,12 @@ class WithingsEnvironmentDataPlatform {
437
553
  if (!alreadyNotifiedForThisReading) {
438
554
  this.staleNotifiedForReadingDate = this.lastReadingDate;
439
555
  this.persistState();
440
- await this.sendNtfyNotification(
441
- `Temperature and/or CO2 readings haven't been updated since ${date} at ${time}.`,
442
- 'Homebridge: Withings Environment Data is out of date'
443
- );
556
+ if (this.getSendStaleDataNotification()) {
557
+ await this.sendNtfyNotification(
558
+ `Temperature and/or CO2 readings haven't been updated since ${date} at ${time}.`,
559
+ 'Homebridge: Withings Environment Data is out of date'
560
+ );
561
+ }
444
562
  }
445
563
  } else if (this.staleNotifiedForReadingDate !== null) {
446
564
  this.staleNotifiedForReadingDate = null;
@@ -481,7 +599,7 @@ class WithingsEnvironmentDataPlatform {
481
599
  return this.lastReading.temperature;
482
600
  }
483
601
 
484
- applyReading(co2, temperature, readingDate) {
602
+ applyReading(co2, temperature, readingDate, co2Series = [], tempSeries = []) {
485
603
  const co2Threshold = this.getCo2Threshold();
486
604
  const hasReading = (co2 !== null && co2 !== undefined) || (temperature !== null && temperature !== undefined);
487
605
 
@@ -526,7 +644,7 @@ class WithingsEnvironmentDataPlatform {
526
644
  }
527
645
 
528
646
  if (hasReading) {
529
- this.publishMqttReading();
647
+ this.publishMqttReadings(co2Series, tempSeries);
530
648
  }
531
649
  }
532
650
 
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.3",
4
+ "version": "1.5.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": [