homebridge-roborock-matter 3.20.0 → 3.21.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.21.0
4
+
5
+ **You can now change the clean mode while the robot is already cleaning. This needed a change in Homebridge itself, and that change has now shipped.**
6
+
7
+ Until now a mode picked mid-run only moved the picker. The prep sequence that sends the mode to the robot runs before a start and nowhere else, so the robot carried on with the settings it began with, and the tile quietly disagreed with the floor. That was not an oversight in this plugin: Matter's RvcCleanMode cluster forbids a mode change outside idle unless the device advertises the `DirectModeChange` feature, and Homebridge had no way for a plugin to advertise it. [homebridge/homebridge#4001](https://github.com/homebridge/homebridge/pull/4001) added one, and it is merged and released in `2.4.1-beta.11`.
8
+
9
+ **What happens now.** A mode change while the robot is running or paused is sent to the robot immediately, and the picker holds your choice until the robot's own status agrees with it — up to 150 seconds, after which the picker gives up and follows the robot again with a warning line naming what it asked for. If the command fails outright, every piece of selection state rolls back to what it was and the controller is told the change failed, so a rejected change cannot leave the picker showing a mode the robot never took.
10
+
11
+ **On the empty-tank warning, which had to move with it.** The tank check deliberately read the robot's live water level rather than the picker, on the reasoning that a mid-run selection was never applied and so could not be trusted. That reasoning no longer holds, and left alone it would have raised a tank warning for water the robot had already acknowledged turning off. The applied-type pin is now the authority during the confirmation window.
12
+
13
+ **What you need to run it.** Homebridge `2.4.1-beta.11` or newer. On `2.4.0` the feature declaration is inert — that version has no concept of plugin-advertised Matter features, so nothing changes and nothing breaks. It follows the existing `enableMatterCleanMode` setting: leave it on and live changes are advertised, turn it off and neither the declaration nor the handler exists.
14
+
15
+ 6 new tests.
16
+
17
+ ## 3.20.1
18
+
19
+ **The resource half of the same review: seven things that leaked, hung on, or would have taken the bridge down given the right unlucky moment.**
20
+
21
+ **Every timer this library creates is now unref'd, and cannot become an unhandled rejection.** `src/timers.ts` has stated the policy since it was written — "a pending timer must never be why Homebridge cannot shut down" — and `src/` honours it at every one of its own call sites. `roborockLib` never imported that module, so all ~20 of its timers were ref'd, including each in-flight request timeout of up to 30 seconds, none of which were cleared on shutdown. Both fixes live in the 2 wrapper methods every timer in the library already goes through, so they cover all of them at once. The rejection guard matters more than it looks: Homebridge's `uncaughtException` handler is `process.kill(SIGTERM)` and node routes unhandled rejections into it, so one rejecting poll callback takes the whole bridge down. None can reject today — but `updateDataMinimumData` has no `try` in its 114 lines and survives only because each of its callees happens to catch, which is a contract nobody had written down.
22
+
23
+ **Shutdown now actually stops things.** `stopService()` cleared timers and nothing else. `client.end()` existed in exactly 1 place in the codebase, inside `reconnectClient`, and never on the shutdown path — so in the seconds between Homebridge's SIGTERM and its forced exit, robot frames kept arriving and being dispatched into disposed accessories, and a local socket closing in that window scheduled a _new_ reconnect and wrote diagnostics files into a bridge being dismantled. Both transports are closed now, and every pending request is rejected rather than left hanging.
24
+
25
+ **One throwing `dispose()` no longer skips the rest of shutdown.** The SHUTDOWN handler was a straight-line sequence, so a throw anywhere in it skipped `stopService()` — the step that stops the polling and closes the transports. A shutdown that half-runs is worse than one that fails loudly.
26
+
27
+ **A list that grew by 2 entries a minute, forever.** `processDockType()` runs on every status poll carrying `dock_type`, and the poll site's comment says that is safe because the function is idempotent. It is — for `commands`, `deviceStates` and `consumablesString`, which all use keyed assignment. `resetConsumables` was the one member using `.push`. Measured: 6 entries became 2016 after 1005 polls, about 86,400 a month per robot.
28
+
29
+ **A transient network failure no longer deletes your saved session.** The login error path made no distinction between "Roborock rejected your password" and "DNS was not up yet", and deleted both the stored session and the cached device list for either. On a boot where the router is still coming up that destroyed the only offline snapshot the plugin has. Only an actual refusal clears it now, and an unrecognised failure is treated as transport — deleting a good session costs a re-login, keeping a dead one costs one failed request.
30
+
31
+ **Two smaller ones.** A guard reading `photoGzipChunks != []` compared against a fresh array literal and was therefore always true, so it never guarded anything. And two concurrent `createClient` calls could each build a socket, with the later assignment orphaning the first — its handlers correctly declined to reconnect, so there was no storm, but the file descriptor stayed open for the life of the process. The new claim is released in a `finally`, because a leaked claim would be worse than the leak it prevents: that robot could never reconnect again.
32
+
33
+ 17 new tests.
34
+
3
35
  ## 3.20.0
4
36
 
5
37
  **Two ways the plugin could stop working and never start again. Both silent, both permanent until somebody restarted Homebridge.**
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. 1663 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. 1686 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
 
@@ -90,6 +90,8 @@ Enable **Enable Suction-Level Cleaning Modes** (`enableFanPowerCleanModes`) and
90
90
 
91
91
  The clean mode follows the robot as well: start a vacuum+mop or mop-only clean from the Roborock app (or the robot's buttons) and Apple Home switches to the matching mode during the run — no setup needed.
92
92
 
93
+ **Changing the mode mid-run needs Homebridge `2.4.1-beta.11` or newer.** Matter forbids a clean-mode change outside idle unless the device advertises the `DirectModeChange` feature, and a plugin could not advertise it until [homebridge/homebridge#4001](https://github.com/homebridge/homebridge/pull/4001). With a version that has it, a mode picked while the robot is running or paused is sent to the robot straight away and the picker holds your choice until the robot's status agrees. On `2.4.0` the declaration is inert: the picker still only takes effect on the next start, exactly as before.
94
+
93
95
  > ⚠️ **Re-pairing required:** Matter locks an accessory's mode list at commissioning. After enabling (or disabling) this option, restart Homebridge, then **remove the robot from Apple Home and pair it again** — otherwise the new modes will not appear. The same applies to any option that changes announced capabilities.
94
96
 
95
97
  > 🛑 **Turn a suction level off in Apple Home _before_ you disable this option.** Matter stores the selected clean mode on disk and restores it on every start, but it does not store the list of modes it is allowed to come from. So if a suction level (mode 3–7) is the one selected when you disable the option, the restored value is no longer in the announced list, and Matter refuses to bring the accessory up at all:
@@ -255,7 +257,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
255
257
 
256
258
  ## Contributing
257
259
 
258
- Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1663 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.
260
+ Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1686 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.
259
261
 
260
262
  ## Support the project
261
263
 
@@ -6,6 +6,7 @@ const timers_1 = require("./timers");
6
6
  const { getModelNameWithoutBrand } = require("../roborockLib/lib/deviceFeatures");
7
7
  const MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS = 2000;
8
8
  const MATTER_CLEAN_MODE_PREP_TIMEOUT_MS = 2500;
9
+ const MATTER_CLEAN_MODE_LIVE_CONFIRMATION_TIMEOUT_MS = 150 * 1000;
9
10
  // Commands reach this class from two places now: the Matter clusters, and the
10
11
  // optional HAP action switches. The surface is threaded through the command
11
12
  // path rather than inferred, because the only way a user can tell a dropped
@@ -617,6 +618,7 @@ class RoborockMatterVacuumAccessory {
617
618
  this.serviceAreaProgress = [];
618
619
  this.selectedCleanMode = CLEAN_MODE_VACUUM;
619
620
  this.selectedCleanModeNeedsApply = false;
621
+ this.selectedCleanModeLiveConfirmationExpiresAt = 0;
620
622
  /**
621
623
  * Whether `selectedCleanMode` holds a choice or merely its initial value.
622
624
  *
@@ -709,6 +711,12 @@ class RoborockMatterVacuumAccessory {
709
711
  prepWindowMs: MATTER_CLEAN_MODE_PREP_TIMEOUT_MS,
710
712
  };
711
713
  }
714
+ getMatterCleanModeLiveCommandOptions() {
715
+ return {
716
+ ...this.getMatterCleanModePrepCommandOptions(),
717
+ context: "during cleaning",
718
+ };
719
+ }
712
720
  /** The robot's name exactly as the log lines and Apple Home spell it. */
713
721
  getDisplayName() {
714
722
  return this.getVacuumName();
@@ -954,14 +962,11 @@ class RoborockMatterVacuumAccessory {
954
962
  * the sort.
955
963
  *
956
964
  * WHERE THIS DELIBERATELY DIFFERS FROM getCurrentCleanMode(), because the
957
- * difference looks like an oversight and is not: there, a Matter selection
958
- * that has not been applied yet outranks the robot's live report, so the
959
- * mode picker keeps showing what the user just asked for instead of
960
- * flickering back. Here it does not. A selection made mid-run is not applied
961
- * mid-run — the prep only runs before a start — so the robot carries on with
962
- * the water it already had, and a robot physically mopping with an empty
963
- * tank is blocked no matter what the picker shows. The picker reports
964
- * intent; this reports what is happening to the floor.
965
+ * difference looks like an oversight and is not: there, an acknowledged
966
+ * Matter selection stays visible while the robot's status catches up. Here
967
+ * the applied-type pin below is the authority during that same window, so a
968
+ * stale water report cannot invent a tank warning for water the robot has
969
+ * already acknowledged turning off.
965
970
  *
966
971
  * The derived type goes through acceptLiveCleanType() like every other
967
972
  * consumer of it, and that gate is right here for the same reason it is
@@ -1489,25 +1494,52 @@ class RoborockMatterVacuumAccessory {
1489
1494
  async changeCleanMode(newMode) {
1490
1495
  const name = this.getVacuumName();
1491
1496
  this.platform.log.info(`Matter clean mode request for ${name}: ${newMode !== null && newMode !== void 0 ? newMode : "unknown"}.`);
1492
- if (this.isSupportedCleanMode(newMode)) {
1493
- this.rememberCurrentRoborockCleanModeSettings();
1494
- this.selectedCleanMode = newMode;
1495
- this.selectedCleanModeNeedsApply = true;
1496
- this.userSelectedCleanMode = true;
1497
- // Discard the level remembered from the robot: from here on the user
1498
- // has said what they want, and an unreadable fan power must fall back
1499
- // to their choice rather than to what the robot said before it. The
1500
- // applied-type pin goes for the same reason — it records an older
1501
- // intent, and this selection supersedes it.
1502
- this.lastResolvedFanPowerCleanMode = null;
1503
- this.appliedCleanTypePin = null;
1504
- const state = {
1505
- rvcCleanMode: { currentMode: newMode },
1506
- };
1507
- this.setAndScheduleOptimisticState(state, "clean mode change");
1497
+ if (!this.isSupportedCleanMode(newMode)) {
1498
+ this.platform.log.warn(`Ignoring unsupported Matter clean mode '${newMode}' for ${name}.`);
1508
1499
  return;
1509
1500
  }
1510
- this.platform.log.warn(`Ignoring unsupported Matter clean mode '${newMode}' for ${name}.`);
1501
+ const previousSelection = {
1502
+ selectedCleanMode: this.selectedCleanMode,
1503
+ selectedCleanModeNeedsApply: this.selectedCleanModeNeedsApply,
1504
+ selectedCleanModeLiveConfirmationExpiresAt: this.selectedCleanModeLiveConfirmationExpiresAt,
1505
+ userSelectedCleanMode: this.userSelectedCleanMode,
1506
+ lastResolvedFanPowerCleanMode: this.lastResolvedFanPowerCleanMode,
1507
+ appliedCleanTypePin: this.appliedCleanTypePin,
1508
+ };
1509
+ // These are robot-reported preferences, not speculative selection
1510
+ // bookkeeping. If the command fails they remain valid pre-command truth,
1511
+ // so the rollback below intentionally does not restore them.
1512
+ this.rememberCurrentRoborockCleanModeSettings();
1513
+ this.selectedCleanMode = newMode;
1514
+ this.selectedCleanModeNeedsApply = true;
1515
+ this.selectedCleanModeLiveConfirmationExpiresAt = 0;
1516
+ this.userSelectedCleanMode = true;
1517
+ // Discard the level remembered from the robot: from here on the user has
1518
+ // said what they want, and an unreadable fan power must fall back to their
1519
+ // choice rather than to what the robot said before it.
1520
+ this.lastResolvedFanPowerCleanMode = null;
1521
+ this.appliedCleanTypePin = null;
1522
+ const operationalState = this.getOperationalState();
1523
+ const canApplyLive = operationalState === RVC_OPERATIONAL_STATE.RUNNING ||
1524
+ operationalState === RVC_OPERATIONAL_STATE.PAUSED;
1525
+ try {
1526
+ if (canApplyLive) {
1527
+ await this.applyCleanModeDuringRun(newMode);
1528
+ }
1529
+ }
1530
+ catch (error) {
1531
+ this.selectedCleanMode = previousSelection.selectedCleanMode;
1532
+ this.selectedCleanModeNeedsApply =
1533
+ previousSelection.selectedCleanModeNeedsApply;
1534
+ this.selectedCleanModeLiveConfirmationExpiresAt =
1535
+ previousSelection.selectedCleanModeLiveConfirmationExpiresAt;
1536
+ this.userSelectedCleanMode = previousSelection.userSelectedCleanMode;
1537
+ this.lastResolvedFanPowerCleanMode =
1538
+ previousSelection.lastResolvedFanPowerCleanMode;
1539
+ this.appliedCleanTypePin = previousSelection.appliedCleanTypePin;
1540
+ throw error;
1541
+ }
1542
+ this.setAndScheduleOptimisticState({ rvcCleanMode: { currentMode: newMode } }, "clean mode change");
1511
1543
  }
1512
1544
  async pauseCleaning(surface = exports.MATTER_SURFACE) {
1513
1545
  const roborockState = this.getNumberStatus("state");
@@ -2097,6 +2129,25 @@ class RoborockMatterVacuumAccessory {
2097
2129
  }
2098
2130
  return ((_a = FAN_POWER_CLEAN_MODES.find((powerMode) => powerMode.mode === cleanMode)) !== null && _a !== void 0 ? _a : null);
2099
2131
  }
2132
+ doesLiveCleanModeMatch(cleanMode) {
2133
+ const expectedBaseType = this.getBaseCleanType(cleanMode);
2134
+ const liveType = this.getLiveCleanType();
2135
+ if (liveType !== null && !this.acceptLiveCleanType(liveType)) {
2136
+ return false;
2137
+ }
2138
+ if (liveType !== null &&
2139
+ this.getBaseCleanType(liveType) !== expectedBaseType) {
2140
+ return false;
2141
+ }
2142
+ const fanPowerMode = this.getFanPowerCleanMode(cleanMode);
2143
+ if (fanPowerMode) {
2144
+ // Some robots omit a live clean type, but fan power independently
2145
+ // confirms a suction variant. Base clean-type changes have no equivalent
2146
+ // independent signal and still require the robot to report a live type.
2147
+ return this.getNumberStatus("fan_power") === fanPowerMode.fanPower;
2148
+ }
2149
+ return liveType !== null;
2150
+ }
2100
2151
  getCurrentCleanMode() {
2101
2152
  let selected = this.isSupportedCleanMode(this.selectedCleanMode)
2102
2153
  ? this.selectedCleanMode
@@ -2110,6 +2161,24 @@ class RoborockMatterVacuumAccessory {
2110
2161
  // selection wins until it has been applied, and outside an active run
2111
2162
  // the (sticky) robot-side setting must not shadow the user's selection.
2112
2163
  const inCleaningRun = this.isInCleaningRunMode(this.getOperationalState());
2164
+ if (this.selectedCleanModeLiveConfirmationExpiresAt > 0) {
2165
+ if (!inCleaningRun) {
2166
+ this.selectedCleanModeLiveConfirmationExpiresAt = 0;
2167
+ }
2168
+ else if (this.doesLiveCleanModeMatch(this.selectedCleanMode)) {
2169
+ this.selectedCleanModeLiveConfirmationExpiresAt = 0;
2170
+ this.appliedCleanTypePin = null;
2171
+ this.clearOptimisticCluster("rvcCleanMode");
2172
+ }
2173
+ else if (Date.now() < this.selectedCleanModeLiveConfirmationExpiresAt) {
2174
+ return selected;
2175
+ }
2176
+ else {
2177
+ this.selectedCleanModeLiveConfirmationExpiresAt = 0;
2178
+ this.appliedCleanTypePin = null;
2179
+ this.platform.log.warn(`Roborock acknowledged the live ${this.getCleanModeLabel(this.selectedCleanMode)} change for ${this.getVacuumName()}, but its status did not converge within ${MATTER_CLEAN_MODE_LIVE_CONFIRMATION_TIMEOUT_MS / 1000} seconds; Apple Home will now follow the robot's live report.`);
2180
+ }
2181
+ }
2113
2182
  this.trackAppliedCleanTypeRun(inCleaningRun);
2114
2183
  // The wind-down is not a mode change, and on a classic robot it looks
2115
2184
  // exactly like one.
@@ -2337,6 +2406,30 @@ class RoborockMatterVacuumAccessory {
2337
2406
  * vacuum-family mode keeps the robot's current suction, a mop-family mode
2338
2407
  * its current water level), so applying pins the clean TYPE and nothing else.
2339
2408
  */
2409
+ async applyCleanModeDuringRun(cleanMode) {
2410
+ var _a;
2411
+ const applySettings = this.api.applyMatterCleanModeSettings;
2412
+ if (typeof applySettings !== "function") {
2413
+ throw new Error("Roborock clean-mode control is unavailable.");
2414
+ }
2415
+ const settings = this.getRoborockCleanModeSettings(cleanMode);
2416
+ if (!settings) {
2417
+ throw new Error(`No Roborock settings exist for ${this.getCleanModeLabel(cleanMode)}.`);
2418
+ }
2419
+ this.platform.log.info(`Applying ${this.getCleanModeLabel(cleanMode)} mode to ${this.getVacuumName()} during cleaning.`);
2420
+ const result = await this.withCleanModePrepTimeout(applySettings.call(this.api, this.getDuid(), settings, this.getMatterCleanModeLiveCommandOptions()));
2421
+ if ((_a = result === null || result === void 0 ? void 0 : result.unconfirmedSettings) === null || _a === void 0 ? void 0 : _a.length) {
2422
+ throw new Error(`Roborock did not confirm the ${result.unconfirmedSettings.join(" and ")} during the live clean-mode change.`);
2423
+ }
2424
+ this.selectedCleanModeNeedsApply = false;
2425
+ this.selectedCleanModeLiveConfirmationExpiresAt =
2426
+ Date.now() + MATTER_CLEAN_MODE_LIVE_CONFIRMATION_TIMEOUT_MS;
2427
+ this.appliedCleanTypePin = {
2428
+ cleanType: this.getBaseCleanType(cleanMode),
2429
+ runObserved: true,
2430
+ reported: false,
2431
+ };
2432
+ }
2340
2433
  async applyCleanModeBeforeStarting() {
2341
2434
  const applySettings = this.api.applyMatterCleanModeSettings;
2342
2435
  if (typeof applySettings !== "function") {
@@ -3699,6 +3792,20 @@ class RoborockMatterVacuumAccessory {
3699
3792
  }
3700
3793
  return this.optimisticClusters;
3701
3794
  }
3795
+ clearOptimisticCluster(cluster) {
3796
+ const optimistic = this.getActiveOptimisticState();
3797
+ if (!optimistic || !(cluster in optimistic)) {
3798
+ return;
3799
+ }
3800
+ const remaining = { ...optimistic };
3801
+ delete remaining[cluster];
3802
+ if (Object.keys(remaining).length === 0) {
3803
+ this.clearOptimisticState();
3804
+ return;
3805
+ }
3806
+ this.optimisticClusters = remaining;
3807
+ this.optimisticGeneration += 1;
3808
+ }
3702
3809
  clearOptimisticState() {
3703
3810
  this.optimisticClusters = null;
3704
3811
  this.optimisticExpiresAt = 0;