homebridge-roborock-matter 3.26.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,62 @@
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
+
34
+ ## 3.27.0
35
+
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.**
37
+
38
+ 3.26.0 asked `user/scene/<id>` which methods it accepts. Issue #22's reporter ran it and the answer came back on the first try:
39
+
40
+ ```
41
+ OPTIONS user/scene/<id> → "" — the resource allows: DELETE,OPTIONS
42
+ ```
43
+
44
+ That is an answer, and not one of the two the thread had planned for. The plan said an `Allow` naming a write verb means the next step is a promised no-op, and a missing `Allow` means we are near the end of what can be measured from outside. What arrived names exactly one method that changes anything, and it removes a schedule rather than toggling one. There is no write there to put behind a HomeKit switch, and a destructive verb is not something to try against someone's live account to see what happens.
45
+
46
+ **What that rules out is the resource, not the feature.** The plugin's own scene-run path reaches `user/scene/<id>/execute`, so sub-resources under a scene id exist and carry verbs the scene itself does not. This release asks the same safe question of the routes that are left:
47
+
48
+ - `user/scene` — the collection, where a REST API most often keeps an update
49
+ - `user/scene/<id>/enable` — the literal candidate for the nested `TIMER` flag the reporter's app flips
50
+
51
+ **And of two controls, which is the part that makes the answers mean anything.** An `Allow` header on its own is not evidence:
52
+
53
+ - `user/scene/device/<duid>` — a **positive** control. This same probe run already read it successfully, so it is mapped beyond doubt. If `OPTIONS` cannot describe even that, the instrument does not see routes and no other answer in the set is worth reading. It is also why the control is this route and not `/execute`: a control must not be a path whose real verb starts a robot.
54
+ - `user/scene/<id>/no-such-subresource-control` — a **negative** control. If a path with nothing behind it also answers with an `Allow` header, the server answers everything and every positive here is an artefact.
55
+
56
+ Together they bound the search instead of extending it: this round either names a route or shows that no later round would.
57
+
58
+ Everything that made the probe safe to ship is unchanged and still bound by tests — silent unless debug logging is on, safe methods only, once per robot per session, cannot throw into the poll it rides on, and only the `Allow` header is taken from a response rather than the header block, which carries session material. A source guard now also pins the two properties that would otherwise fail invisibly: every candidate is narrowed to a safe method at one point, and the negative control cannot be dropped.
59
+
3
60
  ## 3.26.0
4
61
 
5
62
  **The refused route answered, and what it said was that the route exists — just not for the verb we asked with. So this release asks it which verb it does take, without attempting one.**
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. 1854 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 1854 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.26.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": {
@@ -299,6 +299,15 @@ const B01_STATUS_FORCED_GAP_MS = 1500;
299
299
  const B01_STATUS_ACTIVE_GAP_MS = 12000;
300
300
  const B01_STATUS_IDLE_GAP_MS = 25000;
301
301
 
302
+ // The last path segment of the cloud schedule probe's negative control.
303
+ //
304
+ // The probe asks candidate routes which methods they take. An `Allow` header
305
+ // coming back is only evidence if a route that CANNOT exist stays silent, so
306
+ // one deliberately unmappable path is asked the same question. It is a fixed
307
+ // literal rather than a random string so that the same reading is reproducible
308
+ // across restarts and legible in a log a user pastes into an issue.
309
+ const ABSENT_ROUTE_PROBE_SEGMENT = "no-such-subresource-control";
310
+
302
311
  // How many keys of an arbitrary diagnostic object survive compaction.
303
312
  const DIAGNOSTIC_KEY_LIMIT = 30;
304
313
 
@@ -331,6 +340,32 @@ const DIAGNOSTIC_PRIORITY_KEYS = new Set([
331
340
  // ~86k wake-ups per robot per day to serve at most 1440 polls.
332
341
  const CLASSIC_STATUS_TICK_MS = 15000;
333
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
+
334
369
  // Persisted states whose disk flush is debounced (see setStateAsync): they
335
370
  // change on every received robot message, are served from memory, and only
336
371
  // need the on-disk copy for restart survival.
@@ -465,6 +500,8 @@ class Roborock {
465
500
  this.unsupportedPollCommands = new Set();
466
501
  this.loggedPollProfiles = new Set();
467
502
  this.skippedDialectPolls = new Set();
503
+ /** @type {Map<string, number>} duid -> when its slow lane last ran */
504
+ this.lastSlowParameterPollAt = new Map();
468
505
  this.baseURL = options.baseURL || "usiot.roborock.com";
469
506
 
470
507
  this.userData = options.userData || null;
@@ -2042,7 +2079,7 @@ class Roborock {
2042
2079
  return {};
2043
2080
  });
2044
2081
 
2045
- await this.updateHomeData(homeId);
2082
+ await this.updateHomeData(homeId, homedataResult);
2046
2083
 
2047
2084
  await this.createDevices();
2048
2085
 
@@ -2061,8 +2098,14 @@ class Roborock {
2061
2098
  // createDevices() so the boot poll cannot precede the wait above.
2062
2099
  this.startB01StatusLoop();
2063
2100
 
2064
- await this.getNetworkInfo();
2065
- 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
+ ]);
2066
2109
 
2067
2110
  this.bInited = true;
2068
2111
  // A success ends the retry chain and forgets the backoff, so a
@@ -2604,9 +2647,51 @@ class Roborock {
2604
2647
  this.vacuums[duid].getStatusIntervall(); // actually start getStatusIntervall()
2605
2648
  }
2606
2649
 
2607
- initialPolls.push(
2608
- this.updateDataMinimumData(duid, this.vacuums[duid], robotModel)
2609
- );
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
+ }
2610
2695
  }
2611
2696
 
2612
2697
  await Promise.allSettled(initialPolls);
@@ -3229,6 +3314,37 @@ class Roborock {
3229
3314
  * @param {boolean} isB01
3230
3315
  * @returns {Promise<any>}
3231
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
+
3232
3348
  async pollParameter(duid, vacuum, method, isB01) {
3233
3349
  if (isB01 && !b01Q7Adapter.canAnswerV1Method(method)) {
3234
3350
  const key = `${duid}:${method}`;
@@ -3491,6 +3607,13 @@ class Roborock {
3491
3607
  await this.pollParameter(duid, vacuum, "get_room_mapping", isB01);
3492
3608
  }
3493
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
+
3494
3617
  await this.pollParameter(duid, vacuum, "get_consumable", isB01);
3495
3618
 
3496
3619
  await this.pollParameter(duid, vacuum, "get_server_timer", isB01);
@@ -3622,10 +3745,28 @@ class Roborock {
3622
3745
  }
3623
3746
  }
3624
3747
 
3625
- 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 () => {
3626
3754
  await vacuum.getParameter(duid, "get_multi_maps_list");
3627
3755
  await vacuum.getParameter(duid, "get_room_mapping");
3628
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();
3629
3770
  return true;
3630
3771
  } catch (error) {
3631
3772
  this.log.debug(
@@ -3882,12 +4023,26 @@ class Roborock {
3882
4023
  this.log.debug(`Length of message queue: ${this.messageQueue.size}`);
3883
4024
  }
3884
4025
 
3885
- 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) {
3886
4040
  this.log.debug(`Updating HomeData with homeId: ${homeId}`);
3887
4041
  if (this.api) {
3888
4042
  try {
3889
- const home = await this.api.get(`user/homes/${homeId}`);
3890
- const homedata = home.data.result;
4043
+ const homedata = prefetched
4044
+ ? prefetched
4045
+ : (await this.api.get(`user/homes/${homeId}`)).data.result;
3891
4046
 
3892
4047
  if (homedata) {
3893
4048
  this.superviseDeviceIntervals();
@@ -4845,6 +5000,62 @@ class Roborock {
4845
5000
  { label: "sceneMethods", path: scenePath, method: "options" },
4846
5001
  results
4847
5002
  );
5003
+
5004
+ // And that question was answered: the resource allows DELETE and
5005
+ // OPTIONS. It is an answer, and not one of the two the thread's own
5006
+ // decision rule anticipated. The singular scene resource takes exactly
5007
+ // one method that changes anything, and it destroys a schedule rather
5008
+ // than toggling one — so there is no write here to put behind a HomeKit
5009
+ // switch, and a destructive verb aimed at a stranger's live account to
5010
+ // see what happens is not a measurement this project will take.
5011
+ //
5012
+ // That rules out the resource, not the feature. Our own `executeScene`
5013
+ // reaches `user/scene/{id}/execute`, so sub-resources under a scene id
5014
+ // demonstrably exist and carry verbs the scene itself does not. Whether
5015
+ // one of them is the toggle is the last question this instrument can
5016
+ // ask, and it is asked WITH CONTROLS, because on its own an `Allow`
5017
+ // header proves nothing:
5018
+ //
5019
+ // - `user/scene` — the collection, where a REST API most often keeps
5020
+ // an update;
5021
+ // - `user/scene/{id}/enable` — the literal candidate for the nested
5022
+ // flag the reporter's app flips;
5023
+ // - `user/scene/device/{duid}` — the POSITIVE control. This run has
5024
+ // ALREADY read it successfully, so it is mapped beyond doubt. If
5025
+ // OPTIONS cannot describe even that, the instrument does not see
5026
+ // routes and every other answer in this set is noise. It is also
5027
+ // why the control is this route and not `/execute`: a control must
5028
+ // not be a path whose real verb runs a robot.
5029
+ // - a path that cannot exist — the NEGATIVE control. If THAT comes
5030
+ // back with an Allow header, the server answers everything and no
5031
+ // positive here means anything.
5032
+ //
5033
+ // Together they bound the search rather than extend it: this round
5034
+ // either names a route or shows that no later round would.
5035
+ //
5036
+ // Asking costs nothing that could alter a schedule, and that is
5037
+ // measured rather than assumed: the OPTIONS of the scene resource came
5038
+ // back with an EMPTY body and an Allow header, which is a servlet
5039
+ // container answering the request itself instead of handing it to the
5040
+ // handler behind the path.
5041
+ for (const candidate of [
5042
+ { label: "sceneCollectionMethods", path: "user/scene" },
5043
+ { label: "sceneEnableMethods", path: `${scenePath}/enable` },
5044
+ {
5045
+ label: "mappedRouteControl",
5046
+ path: `user/scene/device/${duid}`,
5047
+ },
5048
+ {
5049
+ label: "absentRouteControl",
5050
+ path: `${scenePath}/${ABSENT_ROUTE_PROBE_SEGMENT}`,
5051
+ },
5052
+ ]) {
5053
+ await this.probeOneCloudScheduleRoute(
5054
+ duid,
5055
+ { ...candidate, method: "options" },
5056
+ results
5057
+ );
5058
+ }
4848
5059
  }
4849
5060
 
4850
5061
  await this.updateRoborockDiagnostics(