homebridge-roborock-matter 3.27.0 → 3.28.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
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.28.0
4
+
5
+ **Startup and network traffic, measured rather than felt.**
6
+
7
+ ### The tiles no longer wait for readings no tile displays
8
+
9
+ `initializeDeviceUpdates` awaited the whole first parameter cycle for every robot — 8 serial round-trips per classic robot — and only then did startup set `bInited`, fire the Homebridge callback and register the Matter accessories. Those 8 requests read consumable hours, timers, carpet mode and water-box mode. Not one of them reaches the tile; the tile displays `get_status`, which was not among them.
10
+
11
+ Counted on the critical path between "Homebridge started the plugin" and "the vacuum exists in Apple Home": **up to 12 serial network waits before, 3 after.** The home snapshot was also fetched twice back to back — `v2/user/homes` to build the device list, then `user/homes` again inside `updateHomeData` — and the second one is now handed the first one's payload. The network probe and the first status run concurrently instead of one after the other.
12
+
13
+ On my own network this was a few seconds. On a robot that does not answer locally — the case in [#22](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/22), where every local request runs its full 10-second timeout before falling back to the cloud — it kept the vacuum out of Apple Home for over a minute after every restart.
14
+
15
+ ### The first status is immediate, not 15 seconds away
16
+
17
+ The classic poller is a plain interval, so its first tick landed 15 seconds after start and the tile showed the cloud snapshot until then. The B01 loop already fired its first read immediately for exactly this reason; the classic path now does too. Each robot's first status is awaited before registration, in parallel across robots and capped at 4 seconds, so the tile registers with fresh state but never waits on a robot that will not answer.
18
+
19
+ ### 160 requests an hour became 52
20
+
21
+ Measured per classic robot: the periodic cycle issued 8 requests every 180 seconds — `get_multi_maps_list`, `get_room_mapping`, `get_consumable`, `get_server_timer`, `get_timer`, `get_carpet_mode`, `get_carpet_clean_mode`, `get_water_box_custom_mode` — for values that change roughly once a week. 3 robots in one house: about 280 requests an hour, 6,700 a day, at the Roborock cloud and the robots' local ports.
22
+
23
+ The 6 slow-changing readings now move to a **30-minute lane**. Room mappings deliberately stay on every cycle: a room renamed in the Roborock app should reach Apple Home within minutes, and that refresh now serves from the persisted cache first — the same shape the B01 branch already had — so its 2 round-trips run behind the tile rather than in front of it.
24
+
25
+ | | before | after |
26
+ | ------------- | ---------- | ---------- |
27
+ | classic robot | 160 / hour | ~52 / hour |
28
+ | Q7 (B01) | 60 / hour | ~6 / hour |
29
+
30
+ `get_status` is untouched — still every 15 seconds for a classic robot, still the dedicated B01 loop — because that is the one reading the tile lives on. A schedule written from Apple Home or a diagnostics export that wants fresh numbers can bring the slow lane forward with `requestSlowParameterPoll`.
31
+
32
+ 8 new tests, 6 of which fail against 3.27.0.
33
+
3
34
  ## 3.27.0
4
35
 
5
36
  **The scene resource named its one write verb, and it is the one that deletes. So the search moves off that resource and onto its siblings — asked safely, and this time with controls, so the round either finds the route or proves there is nothing left to find.**
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. 1862 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. 1870 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
 
@@ -258,7 +258,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
258
258
 
259
259
  ## Contributing
260
260
 
261
- Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1862 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.
261
+ Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1870 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.
262
262
 
263
263
  ## Support the project
264
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
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": {
@@ -340,6 +340,32 @@ const DIAGNOSTIC_PRIORITY_KEYS = new Set([
340
340
  // ~86k wake-ups per robot per day to serve at most 1440 polls.
341
341
  const CLASSIC_STATUS_TICK_MS = 15000;
342
342
 
343
+ // HOW OFTEN THE SLOW-CHANGING PARAMETERS ARE RE-READ.
344
+ //
345
+ // Measured before this existed: a classic robot's periodic cycle issued 8
346
+ // requests every 180 seconds - `get_multi_maps_list`, `get_room_mapping`,
347
+ // `get_consumable`, `get_server_timer`, `get_timer`, `get_carpet_mode`,
348
+ // `get_carpet_clean_mode`, `get_water_box_custom_mode` - 160 an hour, all
349
+ // serial, for values that change roughly once a week. Consumable hours tick
350
+ // over during a clean and nowhere else; timers change when the user edits a
351
+ // schedule; carpet and water-box modes change when the user changes a setting.
352
+ // None of them drives the Apple Home tile - that is `get_status`, on its own
353
+ // 15-second tick and untouched by this.
354
+ //
355
+ // Room mappings are deliberately NOT in the slow lane: a room the user renames
356
+ // or splits in the Roborock app should show up in Apple Home within minutes,
357
+ // and the refresh already serves from cache first. Everything else waits.
358
+ //
359
+ // 30 minutes cuts a classic robot from 160 to about 52 requests an hour and a
360
+ // Q7 from 60 to about 46. A diagnostics export still sees the last reading;
361
+ // anything that needs a fresh one can ask for it.
362
+ const SLOW_PARAMETER_POLL_INTERVAL_MS = 30 * 60 * 1000;
363
+
364
+ // The longest startup will wait for a classic robot's first status before the
365
+ // Matter accessories register. A robot that answers does so in well under a
366
+ // second; one that does not must not hold the others' tiles hostage.
367
+ const INITIAL_STATUS_WAIT_CAP_MS = 4000;
368
+
343
369
  // Persisted states whose disk flush is debounced (see setStateAsync): they
344
370
  // change on every received robot message, are served from memory, and only
345
371
  // need the on-disk copy for restart survival.
@@ -474,6 +500,8 @@ class Roborock {
474
500
  this.unsupportedPollCommands = new Set();
475
501
  this.loggedPollProfiles = new Set();
476
502
  this.skippedDialectPolls = new Set();
503
+ /** @type {Map<string, number>} duid -> when its slow lane last ran */
504
+ this.lastSlowParameterPollAt = new Map();
477
505
  this.baseURL = options.baseURL || "usiot.roborock.com";
478
506
 
479
507
  this.userData = options.userData || null;
@@ -2051,7 +2079,7 @@ class Roborock {
2051
2079
  return {};
2052
2080
  });
2053
2081
 
2054
- await this.updateHomeData(homeId);
2082
+ await this.updateHomeData(homeId, homedataResult);
2055
2083
 
2056
2084
  await this.createDevices();
2057
2085
 
@@ -2070,8 +2098,14 @@ class Roborock {
2070
2098
  // createDevices() so the boot poll cannot precede the wait above.
2071
2099
  this.startB01StatusLoop();
2072
2100
 
2073
- await this.getNetworkInfo();
2074
- await this.initializeDeviceUpdates();
2101
+ // Independent, so concurrent: the network probe feeds the LAN
2102
+ // attach that runs later in the background, and the first status
2103
+ // feeds the tile. Neither needs the other, and serially they were
2104
+ // two full round-trip waits where one is enough.
2105
+ await Promise.allSettled([
2106
+ this.getNetworkInfo(),
2107
+ this.initializeDeviceUpdates(),
2108
+ ]);
2075
2109
 
2076
2110
  this.bInited = true;
2077
2111
  // A success ends the retry chain and forgets the backoff, so a
@@ -2613,9 +2647,51 @@ class Roborock {
2613
2647
  this.vacuums[duid].getStatusIntervall(); // actually start getStatusIntervall()
2614
2648
  }
2615
2649
 
2616
- initialPolls.push(
2617
- this.updateDataMinimumData(duid, this.vacuums[duid], robotModel)
2618
- );
2650
+ // THE TILE WAITS FOR NONE OF THIS, SO IT MUST NOT WAIT FOR ANY OF IT.
2651
+ //
2652
+ // `initializeDeviceUpdates` used to await the whole first parameter
2653
+ // cycle for every robot - 8 serial round-trips per classic robot, each
2654
+ // able to spend its full 10-second timeout on a robot that does not
2655
+ // answer locally - and only then did the caller set `bInited`, fire the
2656
+ // Homebridge callback, and register the Matter accessories. On a home
2657
+ // network that added a few seconds to startup. On a robot with a dead
2658
+ // local link it kept the vacuum out of Apple Home for over a minute
2659
+ // after every restart, for readings of consumable hours and carpet
2660
+ // mode that no tile displays.
2661
+ //
2662
+ // The cycle still runs once at start; it just runs behind the tiles.
2663
+ void this.updateDataMinimumData(
2664
+ duid,
2665
+ this.vacuums[duid],
2666
+ robotModel
2667
+ ).catch((error) => {
2668
+ this.log.debug(
2669
+ `Initial parameter cycle failed for ${this.describeDevice(duid)}: ${
2670
+ /** @type {any} */ (error)?.message || error
2671
+ }`
2672
+ );
2673
+ });
2674
+
2675
+ // What the tile IS waiting for is one real status. The classic poller
2676
+ // is a plain interval, so its first tick used to land 15 seconds after
2677
+ // start and the tile showed the cloud snapshot until then; the B01 loop
2678
+ // already fires its first read immediately for exactly this reason.
2679
+ // Each robot's first status is awaited, in parallel across robots and
2680
+ // capped, so registration waits for fresh state but never for a robot
2681
+ // that will not answer.
2682
+ if (!this.isB01Device(duid)) {
2683
+ initialPolls.push(
2684
+ Promise.race([
2685
+ this.getStatus(duid),
2686
+ new Promise((resolve) => {
2687
+ const timer = setTimeout(resolve, INITIAL_STATUS_WAIT_CAP_MS);
2688
+ if (typeof timer?.unref === "function") {
2689
+ timer.unref();
2690
+ }
2691
+ }),
2692
+ ])
2693
+ );
2694
+ }
2619
2695
  }
2620
2696
 
2621
2697
  await Promise.allSettled(initialPolls);
@@ -3238,6 +3314,37 @@ class Roborock {
3238
3314
  * @param {boolean} isB01
3239
3315
  * @returns {Promise<any>}
3240
3316
  */
3317
+ /**
3318
+ * Is it time to re-read a robot's slow-changing parameters?
3319
+ *
3320
+ * The first cycle after start always qualifies (nothing recorded yet), so a
3321
+ * fresh process still reads everything once - in the background, off the
3322
+ * startup critical path.
3323
+ *
3324
+ * @param {string} duid
3325
+ */
3326
+ isSlowParameterPollDue(duid) {
3327
+ const last = this.lastSlowParameterPollAt.get(duid) ?? 0;
3328
+ return Date.now() - last >= SLOW_PARAMETER_POLL_INTERVAL_MS;
3329
+ }
3330
+
3331
+ /** @param {string} duid */
3332
+ markSlowParameterPoll(duid) {
3333
+ this.lastSlowParameterPollAt.set(duid, Date.now());
3334
+ }
3335
+
3336
+ /**
3337
+ * Forget a robot's slow-lane stamp so the next cycle re-reads everything.
3338
+ * Used when something the slow lane owns is known to have changed - a
3339
+ * schedule written from Apple Home, a diagnostics export asking for fresh
3340
+ * numbers.
3341
+ *
3342
+ * @param {string} duid
3343
+ */
3344
+ requestSlowParameterPoll(duid) {
3345
+ this.lastSlowParameterPollAt.delete(duid);
3346
+ }
3347
+
3241
3348
  async pollParameter(duid, vacuum, method, isB01) {
3242
3349
  if (isB01 && !b01Q7Adapter.canAnswerV1Method(method)) {
3243
3350
  const key = `${duid}:${method}`;
@@ -3500,6 +3607,13 @@ class Roborock {
3500
3607
  await this.pollParameter(duid, vacuum, "get_room_mapping", isB01);
3501
3608
  }
3502
3609
 
3610
+ // Everything below this line is the slow lane. See
3611
+ // SLOW_PARAMETER_POLL_INTERVAL_MS for the measurement behind it.
3612
+ if (!this.isSlowParameterPollDue(duid)) {
3613
+ return;
3614
+ }
3615
+ this.markSlowParameterPoll(duid);
3616
+
3503
3617
  await this.pollParameter(duid, vacuum, "get_consumable", isB01);
3504
3618
 
3505
3619
  await this.pollParameter(duid, vacuum, "get_server_timer", isB01);
@@ -3631,10 +3745,28 @@ class Roborock {
3631
3745
  }
3632
3746
  }
3633
3747
 
3634
- try {
3748
+ // Same shape as the B01 branch above, for the same reason: with a room
3749
+ // list already persisted from an earlier run, Service Area can expose it
3750
+ // the moment the accessory registers, and the 2 round-trips that refresh
3751
+ // it run behind the tile instead of in front of it. Only a robot the
3752
+ // plugin has never seen rooms for is worth waiting on.
3753
+ const refresh = async () => {
3635
3754
  await vacuum.getParameter(duid, "get_multi_maps_list");
3636
3755
  await vacuum.getParameter(duid, "get_room_mapping");
3637
3756
  await this.cacheMissingMatterServiceAreaRoomMappings(duid, vacuum);
3757
+ };
3758
+
3759
+ if (this.getRoomMappingsForDevice(duid).length > 0) {
3760
+ void refresh().catch((error) => {
3761
+ this.log.debug(
3762
+ `Background room refresh failed for ${duid}: ${error.message || error}`
3763
+ );
3764
+ });
3765
+ return true;
3766
+ }
3767
+
3768
+ try {
3769
+ await refresh();
3638
3770
  return true;
3639
3771
  } catch (error) {
3640
3772
  this.log.debug(
@@ -3891,12 +4023,26 @@ class Roborock {
3891
4023
  this.log.debug(`Length of message queue: ${this.messageQueue.size}`);
3892
4024
  }
3893
4025
 
3894
- async updateHomeData(homeId) {
4026
+ /**
4027
+ * Refresh the home snapshot - devices, products, local keys - and let every
4028
+ * robot's polling intervals re-check themselves against it.
4029
+ *
4030
+ * @param {string | number} homeId
4031
+ * @param {Record<string, any>} [prefetched] a home payload already fetched
4032
+ * by the caller. Startup fetched `v2/user/homes/{id}` seconds earlier,
4033
+ * built the device list from it, and then called this, which fetched
4034
+ * `user/homes/{id}` again before the Matter accessories could register:
4035
+ * 2 cloud round-trips for one snapshot, back to back, on the critical
4036
+ * path. With the payload handed in, this consumes it. The periodic
4037
+ * caller passes nothing and fetches fresh, as before.
4038
+ */
4039
+ async updateHomeData(homeId, prefetched) {
3895
4040
  this.log.debug(`Updating HomeData with homeId: ${homeId}`);
3896
4041
  if (this.api) {
3897
4042
  try {
3898
- const home = await this.api.get(`user/homes/${homeId}`);
3899
- const homedata = home.data.result;
4043
+ const homedata = prefetched
4044
+ ? prefetched
4045
+ : (await this.api.get(`user/homes/${homeId}`)).data.result;
3900
4046
 
3901
4047
  if (homedata) {
3902
4048
  this.superviseDeviceIntervals();