homebridge-roborock-matter 3.19.4 → 3.19.5

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
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.19.5
4
+
5
+ **A room-list refresh that cannot succeed is no longer re-attempted by every periodic poll. On a Q10 it is not attempted at all, and on a Q7 whose map channel is down it now backs off instead of running 480 guaranteed-to-fail map reads a day.**
6
+
7
+ Room names on B01 robots come from the map channel, and because rooms rarely change a successful fetch is good for six hours. That six-hour stamp was written only on success — correct for the happy path, and the whole story for a refresh that never completes one. A robot that cannot answer never closes the throttle, so it was asked again by every periodic poll for as long as the plugin ran.
8
+
9
+ **On a Q10 (`ss*`) the answer could never arrive.** `get_map_list` has no Q10 translation, so the send choke point refuses it by name before anything reaches the wire. That refusal is correct and is caught quietly at debug level, but it is certain before the request is made, and it was being repeated every three minutes forever. This is the third loop of that exact shape: the status loop was gated in 3.19.0 and the live-room loop in 3.19.1, and this one was missed both times. It is now gated at the same place and for the same reason — at the function entry, because both call sites reach it through a check that matches _both_ B01 dialects.
10
+
11
+ **On a Q7 (`sc*`) the request is not refused, so the same missing guard cost real work.** A robot whose map channel is down ran a `get_map_list` on the wire plus a map read that waited out its full 20-second timeout, once per poll cycle — roughly 480 attempts a day for a room list that was not going to arrive, and in the uncached case it delayed the rest of the poll chain by that timeout each time.
12
+
13
+ Repeated failures now widen the gap, following the same rule the live-room fetch already used. The first failure is deliberately not slowed, so a single lost frame on a healthy channel still costs nothing. Past that the gap doubles from two poll cycles and is capped at 30 minutes, which stays far below the six-hour success cadence — a channel that comes back is picked up within the same half hour rather than at the next scheduled refresh. A success clears the accumulated penalty outright.
14
+
15
+ A reply that arrives but reports no current map is explicitly not counted as a failure. That is a robot still building its first map, and it must not be asked ever more rarely precisely while the answer is about to become available.
16
+
17
+ This changes retry timing only. No new request is introduced, and nothing is published to Apple Home that was not published before.
18
+
3
19
  ## 3.19.4
4
20
 
5
21
  **An unmapped `error_code` is now logged with the state the robot was in when it appeared, because these codes turn out to describe transitions rather than faults — and a bare number cannot be reported usefully or mapped later.**
package/README.md CHANGED
@@ -37,7 +37,7 @@ This is the most feature-packed, most thoroughly engineered Roborock plugin for
37
37
  - 📍 **See where it's cleaning — live.** Apple Home shows _"Cleaning — Kitchen"_ with the room the robot is actually inside, updating as it moves from room to room. Works even for cleans started from the robot's button or the Roborock app. No other Homebridge plugin does this.
38
38
  - 🧭 **One robot, one tile — and as many robots as you own.** Sign in once and your whole fleet comes along: every vacuum on your account appears as its own clean, native accessory in Apple Home. No clutter of fake fans and helper switches, and rooms appear with the names you gave them in the Roborock app.
39
39
  - ⚡ **Fast and reliable.** Commands go directly to the robot over your own network whenever possible, with the Roborock cloud as automatic backup — and built-in diagnostics in the settings if you ever want to look under the hood.
40
- - 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 1624 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
40
+ - 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 1636 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
41
41
 
42
42
  ## Features
43
43
 
@@ -253,7 +253,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
253
253
 
254
254
  ## Contributing
255
255
 
256
- Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1624 tests (protocol fixtures verified against the [python-roborock](https://github.com/Python-roborock/python-roborock) reference), strict TypeScript checking, and CI across Node 22/24 × Homebridge 1.11/2.x — `npm test` before you push and you're set.
256
+ Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1636 tests (protocol fixtures verified against the [python-roborock](https://github.com/Python-roborock/python-roborock) reference), strict TypeScript checking, and CI across Node 22/24 × Homebridge 1.11/2.x — `npm test` before you push and you're set.
257
257
 
258
258
  ## Support the project
259
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.19.4",
3
+ "version": "3.19.5",
4
4
  "description": "The most complete Roborock plugin for Apple Home. Supports the entire Roborock lineup — from the classic S-series to the new 2025 Q7 series that no other plugin can control. Sign in with your Roborock account and get native start/stop, room cleaning, suction levels, battery, and live 'cleaning in the kitchen' room tracking. Verified by Homebridge.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -117,6 +117,45 @@ function liveRoomFetchGapMs(consecutiveFailures) {
117
117
  );
118
118
  }
119
119
 
120
+ // Rooms rarely change, so a successful room-list refresh is good for 6 hours.
121
+ const B01_ROOM_REFRESH_GAP_MS = 6 * 60 * 60 * 1000;
122
+
123
+ // The same reasoning as the live-room backoff above, one loop further out. The
124
+ // 6-hour stamp is written only on success, so a robot whose map channel is down
125
+ // falls through to the periodic poller's own cadence — 180 s by default — and
126
+ // spends a real `get_map_list` plus a map read that waits out its full 20 s
127
+ // timeout, 480 times a day, for a room list that is not going to arrive.
128
+ //
129
+ // The first failure is deliberately NOT slowed: a single lost frame on a
130
+ // healthy channel must not cost a room list, the same rule the live-room gap
131
+ // follows. Past that the gap doubles from two poll cycles — a gap equal to the
132
+ // cadence that produced the failure would delay nothing — and the cap stays far
133
+ // below the 6-hour success cadence so a channel that comes back is picked up in
134
+ // the same half hour rather than at the next scheduled refresh.
135
+ const ROOM_REFRESH_BACKOFF_AFTER = 1;
136
+ const B01_ROOM_REFRESH_RETRY_BASE_MS = 180000; // one periodic poll
137
+ const B01_ROOM_REFRESH_RETRY_MAX_MS = 1800000; // 30 min
138
+
139
+ /**
140
+ * Required gap before the next room-list refresh attempt, given how many
141
+ * attempts in a row have already failed.
142
+ * @param {number} [consecutiveFailures]
143
+ * @returns {number}
144
+ */
145
+ function roomRefreshRetryGapMs(consecutiveFailures) {
146
+ const over = Number(consecutiveFailures) - ROOM_REFRESH_BACKOFF_AFTER;
147
+ if (!Number.isFinite(over) || over <= 0) {
148
+ return 0;
149
+ }
150
+ // Doubling starts at two poll cycles, not one: a gap equal to the cadence
151
+ // that produced the failure delays nothing, so the first widened step has to
152
+ // clear it to be a step at all.
153
+ return Math.min(
154
+ B01_ROOM_REFRESH_RETRY_BASE_MS * 2 ** over,
155
+ B01_ROOM_REFRESH_RETRY_MAX_MS
156
+ );
157
+ }
158
+
120
159
  /**
121
160
  * Zero the counters that describe THIS run. clearLiveRoomForDevice runs at
122
161
  * every run boundary and its stated job is to stop state leaking into the next
@@ -4663,40 +4702,98 @@ class Roborock {
4663
4702
  * service.upload_by_mapid -> MAP_RESPONSE (protocol 301) -> AES-ECB/zlib
4664
4703
  * decode -> SCMap protobuf -> {roomId, roomName}. Rooms rarely change, so
4665
4704
  * refreshes are throttled to once per 6 hours unless forced.
4705
+ *
4706
+ * The 6-hour stamp is written only on SUCCESS, which is right for the happy
4707
+ * path and was the whole story for a refresh that cannot succeed: a robot
4708
+ * that never completes one never closes the throttle either, so it is asked
4709
+ * again by every periodic poll. Both cases below exist to bound that.
4710
+ * @param {string} duid
4711
+ * @param {{force?: boolean}} [options]
4712
+ * @returns {Promise<any[]>}
4666
4713
  */
4667
4714
  async refreshB01Rooms(duid, options = {}) {
4715
+ // A Q10 (`ss*`) IS NOT FETCHED AT ALL — the third loop of this shape, after
4716
+ // the status loop (3.19.0) and the live-room loop (3.19.1). `get_map_list`
4717
+ // is not in NEUTRAL_RESPONSES and has no Q10 translation, so the send choke
4718
+ // point refuses it by name and throws B01_METHOD_UNSUPPORTED. The caller
4719
+ // catches that at debug level, so it is quiet — but the refusal is certain
4720
+ // before the request is asked, and because it never succeeds the 6-hour
4721
+ // throttle below is never stamped. A Q10 therefore repeats a designed
4722
+ // refusal on every poll for as long as the plugin runs.
4723
+ //
4724
+ // Gated at the entry rather than at the two call sites for the same reason
4725
+ // `refreshB01LiveRoom` is: both of them reach here through
4726
+ // `refreshMatterServiceAreaRoomMappings`, which gates on `isB01Protocol` —
4727
+ // and that is BOTH dialects, the premise of #19. Returning the cache is
4728
+ // what the throttled path above already returns, so every caller handles
4729
+ // it, and no failure state is allocated for a request never made.
4730
+ if (
4731
+ b01Q7Adapter.b01FamilyForModel(
4732
+ this.getProductAttribute(duid, "model")
4733
+ ) === b01Q7Adapter.B01_FAMILY.Q10
4734
+ ) {
4735
+ return this.getB01RoomCache(duid);
4736
+ }
4737
+
4668
4738
  if (!this._b01RoomRefreshAt) {
4669
4739
  this._b01RoomRefreshAt = new Map();
4670
4740
  }
4671
4741
  const lastAt = this._b01RoomRefreshAt.get(duid) || 0;
4672
- if (!options.force && Date.now() - lastAt < 6 * 60 * 60 * 1000) {
4742
+ if (!options.force && Date.now() - lastAt < B01_ROOM_REFRESH_GAP_MS) {
4673
4743
  return this.getB01RoomCache(duid);
4674
4744
  }
4675
4745
 
4676
- const mapListData = await this.messageQueueHandler.sendRequest(
4677
- duid,
4678
- "get_map_list",
4679
- {}
4680
- );
4681
- const mapId = b01Q7Adapter.findCurrentMapId(mapListData);
4682
- if (mapId === null) {
4683
- this.log.debug(`No B01 map available yet for ${duid}; rooms deferred.`);
4746
+ if (!this._b01RoomRefreshFailures) {
4747
+ this._b01RoomRefreshFailures = new Map();
4748
+ }
4749
+ const failureState = this._b01RoomRefreshFailures.get(duid);
4750
+ if (
4751
+ !options.force &&
4752
+ failureState &&
4753
+ Date.now() - failureState.lastAttemptAt <
4754
+ roomRefreshRetryGapMs(failureState.consecutiveFailures)
4755
+ ) {
4684
4756
  return this.getB01RoomCache(duid);
4685
4757
  }
4686
4758
 
4687
- const rawPayload = await this.sendB01MapRequest(duid, mapId);
4688
- const serial = this.getVacuumDeviceInfo(duid, "sn");
4689
- const model = this.getProductAttribute(duid, "model");
4690
- const mapKey = b01Q7Adapter.createMapKey(serial, model);
4691
- const scMap = b01Q7Adapter.decodeMapPayload(rawPayload, mapKey);
4692
- const rooms = b01Q7Adapter.parseRoomsFromScMap(scMap);
4759
+ try {
4760
+ const mapListData = await this.messageQueueHandler.sendRequest(
4761
+ duid,
4762
+ "get_map_list",
4763
+ {}
4764
+ );
4765
+ const mapId = b01Q7Adapter.findCurrentMapId(mapListData);
4766
+ if (mapId === null) {
4767
+ // A reply arrived, so the channel is up; there is simply no map yet.
4768
+ // That is not a failure and must not accrue a backoff, or a robot
4769
+ // still building its first map would be asked ever more rarely
4770
+ // precisely while the answer is about to become available.
4771
+ this._b01RoomRefreshFailures.delete(duid);
4772
+ this.log.debug(`No B01 map available yet for ${duid}; rooms deferred.`);
4773
+ return this.getB01RoomCache(duid);
4774
+ }
4693
4775
 
4694
- this._b01RoomRefreshAt.set(duid, Date.now());
4695
- await this.setB01RoomCache(duid, rooms);
4696
- this.log.info(
4697
- `B01 rooms for ${this.describeDevice(duid)}: ${rooms.length ? rooms.map((room) => `${room.roomName || "?"} (${room.roomId})`).join(", ") : "none reported"}.`
4698
- );
4699
- return rooms;
4776
+ const rawPayload = await this.sendB01MapRequest(duid, mapId);
4777
+ const serial = this.getVacuumDeviceInfo(duid, "sn");
4778
+ const model = this.getProductAttribute(duid, "model");
4779
+ const mapKey = b01Q7Adapter.createMapKey(serial, model);
4780
+ const scMap = b01Q7Adapter.decodeMapPayload(rawPayload, mapKey);
4781
+ const rooms = b01Q7Adapter.parseRoomsFromScMap(scMap);
4782
+
4783
+ this._b01RoomRefreshFailures.delete(duid);
4784
+ this._b01RoomRefreshAt.set(duid, Date.now());
4785
+ await this.setB01RoomCache(duid, rooms);
4786
+ this.log.info(
4787
+ `B01 rooms for ${this.describeDevice(duid)}: ${rooms.length ? rooms.map((room) => `${room.roomName || "?"} (${room.roomId})`).join(", ") : "none reported"}.`
4788
+ );
4789
+ return rooms;
4790
+ } catch (error) {
4791
+ this._b01RoomRefreshFailures.set(duid, {
4792
+ lastAttemptAt: Date.now(),
4793
+ consecutiveFailures: (failureState?.consecutiveFailures || 0) + 1,
4794
+ });
4795
+ throw error;
4796
+ }
4700
4797
  }
4701
4798
 
4702
4799
  async sendB01MapRequest(duid, mapId) {