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 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.2
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). Leave blank to disable.
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
 
@@ -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
- // Both series are newest-first, so the first entry is the latest reading.
61
- co2: co2Series.length > 0 ? co2Series[0].value : null,
62
- temperature: tempSeries.length > 0 ? tempSeries[0].value : null,
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
- this.sessionKey = this.loadSessionKey();
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
- loadSessionKey() {
134
+ loadPersistedState() {
112
135
  try {
113
136
  const raw = fs.readFileSync(this.sessionStatePath, 'utf8');
114
- return JSON.parse(raw).sessionKey ?? null;
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 null;
142
+ return {};
120
143
  }
121
144
  }
122
145
 
123
- saveSessionKey(sessionKey) {
146
+ persistState() {
124
147
  try {
125
- fs.writeFileSync(this.sessionStatePath, JSON.stringify({ sessionKey }));
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.saveSessionKey(result.sessionKey);
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(errorMessage) {
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: 'Homebridge: Getting Withings Environment Data Failed!' },
283
- body: errorMessage,
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.2",
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": [