homebridge-roborock-matter 3.2.0 → 3.3.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
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.1
4
+
5
+ Two field reports arrived within an hour of 3.3.0 and both came down to the same thing: the log and the diagnostics report were answering questions nobody had asked while staying silent on the one that mattered. This release is almost entirely about making the plugin legible.
6
+
7
+ - **The live-room log said "the robot may be between rooms" for four different problems.** The resolver returns nothing when the map payload has no header, when it carries no robot position, when it carries no room outlines, or when the position genuinely falls outside every outline — and only the last of those is "between rooms". A day of field logs produced 51 of these messages, every one of them asserting a cause that may not have been the cause. Each case is now named, with the number of outlines in the map and the computed position cell, so a coordinate problem is visible in the log instead of needing a debug build.
8
+ - **The message identifying a misbehaving robot was the one you could not read.** The failure line printed a raw 22-character duid while the success line beside it printed the robot's name. In a three-robot house that is the difference between a usable log and a wall of identifiers. The live-room, B01 status and B01 room lines all use the name now.
9
+ - **The attempt counter appeared to reset at random.** A robot resolving back into the room it was already in silently zeroed the miss counter without logging anything, so "attempt 15" was followed by "attempt 5" with nothing in between. Re-entering a known room after a run of misses now says so.
10
+ - **The startup line announced a cadence the code stopped using.** 3.2.0 changed the at-rest B01 poll from 45 s to 25 s and left the message advertising 45 s — misleading in exactly the area it was meant to explain. The cadence values are named constants now and the message is derived from them, so they cannot drift apart again.
11
+ - **The diagnostics report now lists which Apple Home features are switched on.** A report that omits them cannot answer "why doesn't Apple Home show this?", which is the first question most of them are sent to answer — and it cost a full round-trip with a user who had run the test correctly.
12
+ - **The Matter publish line names the robot, and reports a fault when one is being published.** Previously it printed a duid and said nothing about faults, so there was no way to tell whether Apple Home was showing nothing because the plugin sent nothing.
13
+ - **`operationalError` is no longer part of the accessory's registration snapshot.** It is published on the first runtime update instead, seconds later. Matter commissions the endpoint from that snapshot, and 1.4.61 removed the plugin's fault write precisely because Apple Home reacted badly to it there — so a robot that happens to be faulted when Homebridge starts can no longer change what gets commissioned. The mandatory Matter default covers the gap.
14
+
15
+ ## 3.3.0
16
+
17
+ A robot that has stopped because it is wedged under the sofa has always looked exactly like a robot that finished the job: **Ready**. This release lets the plugin say what is actually wrong — asked for by Wazza151 in [#5](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/5), whose previous Matter bridge showed him when the clean-water tank ran empty.
18
+
19
+ - **New setting: Report faults in Apple Home** (off by default). The robot's own faults — stuck, blocked brush or wheel, missing dust bin, flat battery, unreachable dock — are published as the Matter Error state with the Roborock description attached, instead of being flattened to Ready. Dock and tank conditions — clean-water tank empty, waste-water tank full, dust bag missing, air duct blocked, mop-wash tank full — are published as a Matter fault too, but deliberately do **not** force the Error state: a robot whose waste-water tank is full can still vacuum, and an accessory in Error may be refused a Start command by the controller.
20
+ - **The Error state was never gated for a reason.** `ERROR` (3) is a member of even the basic advertised operational state list, so publishing it was always legal — it was being rewritten to `STOPPED` alongside the states that genuinely did need a gate. That is why no released version has ever shown a Roborock fault in Apple Home.
21
+ - **A detached water tank or mop pad is not a fault.** Both are the normal, correct configuration for a vacuum-only run, so reporting them would leave a permanent warning on every dry robot's tile. They are read, and deliberately ignored.
22
+ - **The fault detail can never cost you the tile.** `operationalError` travels in the same cluster payload as the operational state, so a Matter build that refuses the attribute would otherwise freeze Cleaning/Docked along with it — the reason the explicit write was removed back in 1.4.61. If the write is rejected, the plugin immediately re-publishes without it, logs a warning naming the reason, and stops sending it for the rest of the session. An endpoint that is merely still starting up keeps its normal retry and does not disable the feature.
23
+ - **Diagnostics no longer truncate away the answer.** A Roborock status payload runs to about fifty fields and the export kept the first thirty — which are largely housekeeping, while the twenty it dropped included `dock_error_status`, the single field a question about the dock's water tanks turns on. The fields that matter for a fault report are now always kept, however far down the payload they sit, with the size cap otherwise unchanged and secret redaction untouched.
24
+
3
25
  ## 3.2.0
4
26
 
5
27
  Field feedback on the Q7 series: the live room in Apple Home lagged badly behind the robot. One run took 90 seconds to name the first room; another took seven minutes.
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. 279 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. 380 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
 
@@ -75,7 +75,7 @@ Progress stays honest: a room is only shown as _completed_ once the robot was ac
75
75
  <details>
76
76
  <summary>How it works under the hood</summary>
77
77
 
78
- While a robot is actively cleaning, the plugin fetches its live position from the map channel (throttled to ~20 s, active runs only, nothing while docked or paused) and publishes the room it is inside as the Matter Service Area `currentArea`. Both robot generations are covered: **B01/Q7** robots via the encrypted SCMap protobuf (position ray-cast against per-room boundary outlines), **classic S/Q-series** robots via the RRMap segment grid (position resolved against per-pixel room segments — a single-byte lookup on the raw map buffer, ~1 µs per check).
78
+ While a robot is actively cleaning, the plugin fetches its live position from the map channel (the first room of a run goes out immediately, then ~10 s apart, active runs only, nothing while docked or paused) and publishes the room it is inside as the Matter Service Area `currentArea`. Both robot generations are covered: **B01/Q7** robots via the encrypted SCMap protobuf (position ray-cast against per-room boundary outlines), **classic S/Q-series** robots via the RRMap segment grid (position resolved against per-pixel room segments — a single-byte lookup on the raw map buffer, ~1 µs per check).
79
79
 
80
80
  </details>
81
81
 
@@ -102,17 +102,31 @@ The clean mode follows the robot as well: start a vacuum+mop or mop-only clean f
102
102
 
103
103
  Everything is configurable from the Homebridge UI. The essentials:
104
104
 
105
- | Option | Default | What it does |
106
- | ------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | `email` / password | — | Your Roborock app account (2FA handled in the UI; the session token is stored encrypted) |
108
- | `skipDevices` | — | Comma-separated device IDs the plugin should ignore |
109
- | `enableMatterServiceArea` | `true` | Room/map selection in Apple Home |
110
- | `enableLiveRoomTracking` | `true` | Live current-room from the robot's map position while cleaning |
111
- | `enableMatterCleanMode` | `true` | Vacuum / Mop / Vacuum + Mop mode selection |
112
- | `enableFanPowerCleanModes` | `false` | Adds Quiet / Balanced / Turbo / Max (and Max+ on Q7) suction modes to the Matter mode list. **Re-pair the robot once after toggling** — Matter locks the mode list at pairing |
113
- | `enableMatterPowerSource` | `true` | Battery cluster |
114
- | `cloudOnlyMode` | `false` | Skip local TCP entirely and use the cloud for everything |
115
- | `transientWarningThrottleHours` | `6` | How often recurring transient-timeout warnings may repeat (0 = only in debug) |
105
+ | Option | Default | What it does |
106
+ | ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | `email` / password | — | Your Roborock app account (2FA handled in the UI; the session token is stored encrypted) |
108
+ | `skipDevices` | — | Comma-separated device IDs the plugin should ignore |
109
+ | `enableMatterServiceArea` | `true` | Room/map selection in Apple Home |
110
+ | `enableLiveRoomTracking` | `true` | Live current-room from the robot's map position while cleaning |
111
+ | `enableMatterCleanMode` | `true` | Vacuum / Mop / Vacuum + Mop mode selection |
112
+ | `enableFanPowerCleanModes` | `false` | Adds Quiet / Balanced / Turbo / Max (and Max+ on Q7) suction modes to the Matter mode list. **Re-pair the robot once after toggling** — Matter locks the mode list at pairing |
113
+ | `enableMatterPowerSource` | `true` | Battery cluster |
114
+ | `enableMatterFaultReporting` | `false` | Say why the robot needs attention instead of showing Ready — stuck, empty clean-water tank, full waste-water tank, missing dust bag ([details](#why-the-robot-needs-attention)) |
115
+ | `cloudOnlyMode` | `false` | Skip local TCP entirely and use the cloud for everything |
116
+ | `transientWarningThrottleHours` | `6` | How often recurring transient-timeout warnings may repeat (0 = only in debug) |
117
+
118
+ ## Why the robot needs attention
119
+
120
+ By default a robot that has stopped for any reason shows as **Ready** in Apple Home — whether it finished the job or is wedged under the sofa. Turning on **Report faults in Apple Home** changes that: the robot's own faults are published as the Matter Error state, and the condition behind them is published as a Matter fault so the Home app can say what is wrong rather than just that something is.
121
+
122
+ What gets reported:
123
+
124
+ - **The robot's own faults** — stuck, a blocked brush or wheel, a missing dust bin, a flat battery, a dock it cannot reach. These put the tile into Error, because the robot genuinely cannot work.
125
+ - **Dock and tank conditions** — an empty clean-water tank, a full waste-water tank, a missing dust bag, a blocked air duct, a mop-wash tank that needs emptying. These are reported as a fault but deliberately **do not** put the tile into Error: a robot whose waste-water tank is full can still vacuum, and an accessory in Error may be refused a Start command by the controller.
126
+
127
+ What is deliberately **not** reported: a detached water tank or mop pad. That is the normal, correct configuration for a vacuum-only run, so treating it as a fault would leave a permanent warning on every dry robot's tile.
128
+
129
+ It is off by default for two reasons: a robot reporting Error may be refused a Start command from Apple Home, and the fault attribute has a history with Apple Home commissioning. If Matter ever refuses the attribute, the plugin drops it by itself and logs a warning rather than letting it freeze the tile. How much of this Apple Home actually draws, and in what words, is Apple's decision.
116
130
 
117
131
  ## Battery percentage in Apple Home
118
132
 
@@ -110,6 +110,12 @@
110
110
  "type": "boolean",
111
111
  "default": false
112
112
  },
113
+ "enableMatterFaultReporting": {
114
+ "title": "Report Faults in Apple Home",
115
+ "description": "Show why the robot needs attention instead of leaving it as Ready: a stuck robot reports Error, and conditions such as an empty clean-water tank, a full waste-water tank, a missing dust bag or a blocked brush are published as a Matter fault. Which of them Apple Home renders, and how, is Apple's decision. Off by default because a robot reporting Error may refuse a Start command from Apple Home. Dock conditions never force the Error state, so a robot that can still vacuum stays startable. If Matter refuses the fault attribute, the plugin drops it automatically and logs a warning rather than freezing the tile.",
116
+ "type": "boolean",
117
+ "default": false
118
+ },
113
119
  "matterChargedBatteryThreshold": {
114
120
  "title": "Charged Battery Threshold (%)",
115
121
  "description": "Battery percentage at which a docked robot switches from Charging to Docked on the Apple Home tile. Default 100. Lower it (e.g. 90) if a worn battery makes the robot report fully charged early.",
@@ -145,6 +145,102 @@ const RVC_CHARGING_DOCKED_STATE_LIST = [
145
145
  RVC_OPERATIONAL_STATE.CHARGING,
146
146
  RVC_OPERATIONAL_STATE.DOCKED,
147
147
  ];
148
+ // RVC Operational State ErrorStateEnum, from the Matter cluster definition
149
+ // (src/app/zap-templates/zcl/data-model/chip/operational-state-rvc-cluster.xml).
150
+ // Only the values that shipped with the original RVC device type are used:
151
+ // they are the ones controllers have understood the longest, and a fault the
152
+ // controller cannot name is worse than a generic one it can.
153
+ const RVC_ERROR_STATE = {
154
+ NO_ERROR: 0x00,
155
+ UNABLE_TO_START_OR_RESUME: 0x01,
156
+ UNABLE_TO_COMPLETE_OPERATION: 0x02,
157
+ FAILED_TO_FIND_CHARGING_DOCK: 0x40,
158
+ STUCK: 0x41,
159
+ DUST_BIN_MISSING: 0x42,
160
+ DUST_BIN_FULL: 0x43,
161
+ WATER_TANK_EMPTY: 0x44,
162
+ WATER_TANK_MISSING: 0x45,
163
+ MOP_CLEANING_PAD_MISSING: 0x47,
164
+ LOW_BATTERY: 0x48,
165
+ };
166
+ /**
167
+ * Roborock `error_code` to Matter ErrorStateEnum. The Roborock names are the
168
+ * ones this plugin already shows in `deviceFeatures.js`; several distinct
169
+ * mechanical faults collapse onto STUCK because Matter has no finer-grained
170
+ * standard code for them, which is fine — the human-readable Roborock text
171
+ * rides along in `errorStateDetails`.
172
+ *
173
+ * Codes deliberately absent map to UNABLE_TO_COMPLETE_OPERATION via the
174
+ * fallback: inventing a wrong specific code is worse than a correct vague one.
175
+ */
176
+ const ROBOROCK_ERROR_TO_MATTER = new Map([
177
+ [3, RVC_ERROR_STATE.STUCK], // Wheel floating
178
+ [5, RVC_ERROR_STATE.STUCK], // Main brush blocked
179
+ [6, RVC_ERROR_STATE.STUCK], // Side brush blocked
180
+ [7, RVC_ERROR_STATE.STUCK], // Wheel blocked
181
+ [8, RVC_ERROR_STATE.STUCK], // Device stuck
182
+ [9, RVC_ERROR_STATE.DUST_BIN_MISSING], // Dust bin missing
183
+ [12, RVC_ERROR_STATE.LOW_BATTERY], // Low battery
184
+ [16, RVC_ERROR_STATE.STUCK], // Uneven surface
185
+ [19, RVC_ERROR_STATE.FAILED_TO_FIND_CHARGING_DOCK], // Unpowered charging station
186
+ [22, RVC_ERROR_STATE.FAILED_TO_FIND_CHARGING_DOCK], // Charge sensor problem
187
+ [23, RVC_ERROR_STATE.FAILED_TO_FIND_CHARGING_DOCK], // Dock problem
188
+ [24, RVC_ERROR_STATE.STUCK], // No-go zone or invisible wall detected
189
+ [254, RVC_ERROR_STATE.DUST_BIN_FULL], // Bin full
190
+ ]);
191
+ /**
192
+ * Roborock `dock_error_status` to Matter ErrorStateEnum, with the label the
193
+ * Roborock app uses. Values are the ones the reference implementations agree
194
+ * on; `0` means the dock is healthy.
195
+ *
196
+ * The dirty-water tank has no pre-1.4 standard code, so "waste water tank
197
+ * full" reports as DUST_BIN_FULL — the closest "a container needs emptying"
198
+ * signal every controller understands — with the true wording in the details.
199
+ */
200
+ const ROBOROCK_DOCK_ERROR_TO_MATTER = new Map([
201
+ [
202
+ 34,
203
+ {
204
+ errorStateId: RVC_ERROR_STATE.UNABLE_TO_COMPLETE_OPERATION,
205
+ details: "Dock air duct blocked",
206
+ },
207
+ ],
208
+ [
209
+ 38,
210
+ {
211
+ errorStateId: RVC_ERROR_STATE.WATER_TANK_EMPTY,
212
+ details: "Clean water tank empty",
213
+ },
214
+ ],
215
+ [
216
+ 39,
217
+ {
218
+ errorStateId: RVC_ERROR_STATE.DUST_BIN_FULL,
219
+ details: "Waste water tank full",
220
+ },
221
+ ],
222
+ [
223
+ 44,
224
+ {
225
+ errorStateId: RVC_ERROR_STATE.WATER_TANK_MISSING,
226
+ details: "Dirty water tank latch open",
227
+ },
228
+ ],
229
+ [
230
+ 46,
231
+ {
232
+ errorStateId: RVC_ERROR_STATE.DUST_BIN_MISSING,
233
+ details: "Dock dust bag missing",
234
+ },
235
+ ],
236
+ [
237
+ 53,
238
+ {
239
+ errorStateId: RVC_ERROR_STATE.UNABLE_TO_COMPLETE_OPERATION,
240
+ details: "Mop cleaning tank full or blocked",
241
+ },
242
+ ],
243
+ ]);
148
244
  const POWER_SOURCE_STATUS = {
149
245
  ACTIVE: 1,
150
246
  UNAVAILABLE: 3,
@@ -252,6 +348,13 @@ class RoborockMatterVacuumAccessory {
252
348
  this.lastWaterBoxMode = null;
253
349
  this.matterInitializationRetryAttempt = 0;
254
350
  this.matterInitializationRetryPending = false;
351
+ // Latched when a Matter write carrying `operationalError` is rejected.
352
+ // `operationalError` travels in the same cluster payload as
353
+ // `operationalState`, so a controller or matter.js build that refuses the
354
+ // attribute would otherwise freeze the whole tile — Cleaning/Docked
355
+ // included — for as long as the setting stays on. One rejection is enough
356
+ // to drop the attribute for the rest of this run.
357
+ this.faultReportingRejected = false;
255
358
  this.returnToDockRetryPending = false;
256
359
  this.matterStateHeartbeatTimer = null;
257
360
  // Serializes every Matter publish so concurrent publishers (live messages,
@@ -359,7 +462,15 @@ class RoborockMatterVacuumAccessory {
359
462
  this.accessory.context = {};
360
463
  }
361
464
  this.accessory.context.duid = duid;
362
- this.accessory.clusters = this.buildClusters();
465
+ // The registration snapshot is the payload Matter commissions the
466
+ // endpoint from, and 1.4.61 removed the plugin's `operationalError` write
467
+ // precisely because Apple Home reacted badly to it there. Keep the
468
+ // declaration byte-identical to a build without fault reporting and let
469
+ // the attribute arrive on the first runtime publish a few seconds later
470
+ // — matter.js supplies the mandatory NoError default in the meantime, so
471
+ // nothing is missing, and a robot that happens to be faulted at
472
+ // Homebridge start can no longer change what gets commissioned.
473
+ this.accessory.clusters = this.stripOperationalError(this.buildClusters());
363
474
  this.accessory.handlers = this.buildHandlers();
364
475
  this.accessory.getState = async (cluster, attribute) => {
365
476
  const clusterState = this.buildCluster(cluster);
@@ -383,7 +494,7 @@ class RoborockMatterVacuumAccessory {
383
494
  }
384
495
  }
385
496
  async updateMatterStateFromRoborock() {
386
- var _a, _b, _c, _d, _e;
497
+ var _a, _b, _c;
387
498
  if (!this.registered) {
388
499
  return;
389
500
  }
@@ -406,7 +517,15 @@ class RoborockMatterVacuumAccessory {
406
517
  const opState = clusters.rvcOperationalState;
407
518
  const runMode = clusters.rvcRunMode;
408
519
  const cleanMode = clusters.rvcCleanMode;
409
- this.platform.log.info(`Matter publish for ${(_b = (_a = this.accessory.context) === null || _a === void 0 ? void 0 : _a.duid) !== null && _b !== void 0 ? _b : this.accessory.UUID}: battery=${typeof halfPercent === "number" ? halfPercent / 2 + "%" : "n/a"}, operationalState=${(_c = opState === null || opState === void 0 ? void 0 : opState.operationalState) !== null && _c !== void 0 ? _c : "n/a"}, runMode=${(_d = runMode === null || runMode === void 0 ? void 0 : runMode.currentMode) !== null && _d !== void 0 ? _d : "n/a"}, cleanMode=${(_e = cleanMode === null || cleanMode === void 0 ? void 0 : cleanMode.currentMode) !== null && _e !== void 0 ? _e : "n/a"}.`);
520
+ // A fault only appears here when one is actually being published, so
521
+ // an unremarkable line stays unremarkable — but when a user reports
522
+ // "Apple Home shows nothing", this is what says whether the plugin
523
+ // sent anything to show.
524
+ const fault = opState === null || opState === void 0 ? void 0 : opState.operationalError;
525
+ const faultSummary = fault && fault.errorStateId
526
+ ? `, fault=${fault.errorStateId}${fault.errorStateDetails ? ` (${fault.errorStateDetails})` : ""}`
527
+ : "";
528
+ this.platform.log.info(`Matter publish for ${this.getVacuumName()}: battery=${typeof halfPercent === "number" ? halfPercent / 2 + "%" : "n/a"}, operationalState=${(_a = opState === null || opState === void 0 ? void 0 : opState.operationalState) !== null && _a !== void 0 ? _a : "n/a"}, runMode=${(_b = runMode === null || runMode === void 0 ? void 0 : runMode.currentMode) !== null && _b !== void 0 ? _b : "n/a"}, cleanMode=${(_c = cleanMode === null || cleanMode === void 0 ? void 0 : cleanMode.currentMode) !== null && _c !== void 0 ? _c : "n/a"}${faultSummary}.`);
410
529
  }
411
530
  this.ensureMatterStateHeartbeat();
412
531
  }
@@ -690,6 +809,14 @@ class RoborockMatterVacuumAccessory {
690
809
  // Drop the record so the cluster is retried on the next snapshot
691
810
  // even if its payload is unchanged.
692
811
  this.lastPublishedClusterJson.delete(cluster);
812
+ // Opt-in fault reporting must never cost the user their tile. If
813
+ // the payload carried `operationalError` and the write was
814
+ // refused for any reason other than an endpoint that is still
815
+ // coming up, retry immediately without it and stop sending it.
816
+ // The operational state itself then still lands.
817
+ if (await this.retryWithoutOperationalError(matter, cluster, attributes, error)) {
818
+ return;
819
+ }
693
820
  failures.push(error);
694
821
  this.platform.log.debug(`Matter publish for cluster ${cluster} on ${this.accessory.UUID} failed: ${error instanceof Error ? error.message : String(error)}`);
695
822
  }
@@ -722,6 +849,38 @@ class RoborockMatterVacuumAccessory {
722
849
  throw error;
723
850
  }
724
851
  }
852
+ /**
853
+ * Recovery for a rejected `operationalError` write.
854
+ *
855
+ * Returns true when the cluster was successfully re-published without the
856
+ * attribute, in which case the caller must treat the write as a success —
857
+ * the operational state landed, only the optional fault detail was dropped.
858
+ * Returns false for payloads that never carried the attribute, for an
859
+ * endpoint that is merely still initializing (that has its own retry, and
860
+ * disabling a feature over a transient startup race would be wrong), and
861
+ * when the retry fails too.
862
+ */
863
+ async retryWithoutOperationalError(matter, cluster, attributes, error) {
864
+ if (!("operationalError" in attributes) ||
865
+ this.isMatterInitializingError(error)) {
866
+ return false;
867
+ }
868
+ const { operationalError: _rejected, ...withoutError } = attributes;
869
+ try {
870
+ await matter.updateAccessoryState(this.accessory.UUID, cluster, withoutError);
871
+ }
872
+ catch (_a) {
873
+ // The write was failing for some other reason. Leave the feature alone
874
+ // and let the caller record a normal failure.
875
+ return false;
876
+ }
877
+ if (!this.faultReportingRejected) {
878
+ this.faultReportingRejected = true;
879
+ this.platform.log.warn(`Matter rejected the fault detail (operationalError) for ${this.getVacuumName()}, so fault reporting has been switched off for this session; the operational state itself is unaffected. Turn off "Report Faults in Apple Home" to silence this. Reason: ${this.getErrorMessage(error)}`);
880
+ }
881
+ this.lastPublishedClusterJson.set(cluster, JSON.stringify(withoutError));
882
+ return true;
883
+ }
725
884
  /**
726
885
  * Publish a full Roborock cluster snapshot, performing a one-time battery
727
886
  * resync per boot first. Matter controllers filter attribute reports by
@@ -869,6 +1028,19 @@ class RoborockMatterVacuumAccessory {
869
1028
  }
870
1029
  return this.applyOptimisticState(clusters);
871
1030
  }
1031
+ /**
1032
+ * A copy of a cluster snapshot with the optional fault attribute removed,
1033
+ * for the one payload where its presence is a commissioning risk rather
1034
+ * than useful information.
1035
+ */
1036
+ stripOperationalError(clusters) {
1037
+ const operationalState = clusters.rvcOperationalState;
1038
+ if (!operationalState || !("operationalError" in operationalState)) {
1039
+ return clusters;
1040
+ }
1041
+ const { operationalError: _omitted, ...rest } = operationalState;
1042
+ return { ...clusters, rvcOperationalState: rest };
1043
+ }
872
1044
  buildCluster(cluster) {
873
1045
  var _a;
874
1046
  let clusterState;
@@ -1194,7 +1366,7 @@ class RoborockMatterVacuumAccessory {
1194
1366
  }
1195
1367
  buildOperationalStateCluster() {
1196
1368
  const operationalState = this.getOperationalState();
1197
- return {
1369
+ const cluster = {
1198
1370
  // RVC Operational State requires PhaseList and CurrentPhase to be null.
1199
1371
  phaseList: null,
1200
1372
  currentPhase: null,
@@ -1204,6 +1376,20 @@ class RoborockMatterVacuumAccessory {
1204
1376
  operationalStateList: this.getOperationalStateList().map((operationalStateId) => ({ operationalStateId })),
1205
1377
  operationalState,
1206
1378
  };
1379
+ if (this.isFaultReportingEnabled()) {
1380
+ // Always write the attribute while the feature is on, never just when
1381
+ // something is wrong: an error that is published but never cleared
1382
+ // leaves the tile complaining about a tank the user refilled an hour
1383
+ // ago. NO_ERROR is the "all clear".
1384
+ const operationalError = this.buildOperationalError();
1385
+ cluster.operationalError =
1386
+ operationalError !== null && operationalError !== void 0 ? operationalError : (operationalState === RVC_OPERATIONAL_STATE.ERROR
1387
+ ? // The robot says it has halted but named no code. Saying
1388
+ // "error, but no error" is worse than a truthful vague answer.
1389
+ { errorStateId: RVC_ERROR_STATE.UNABLE_TO_COMPLETE_OPERATION }
1390
+ : { errorStateId: RVC_ERROR_STATE.NO_ERROR });
1391
+ }
1392
+ return cluster;
1207
1393
  }
1208
1394
  buildPowerSourceCluster(batteryValue, chargeStatusValue, stateValue) {
1209
1395
  const battery = batteryValue === undefined
@@ -1776,6 +1962,68 @@ class RoborockMatterVacuumAccessory {
1776
1962
  isChargingDockedStateEnabled() {
1777
1963
  return (this.platform.platformConfig.enableMatterChargingDockedStates === true);
1778
1964
  }
1965
+ /**
1966
+ * Opt-in fault reporting. Off by default: publishing `operationalError` is
1967
+ * the one thing 1.4.61 removed for Apple Home commissioning safety, and a
1968
+ * robot that reports ERROR may be refused a Start command by the
1969
+ * controller — so a spurious fault would cost the user their tile. The
1970
+ * write also self-disables at runtime if Matter rejects it
1971
+ * (`faultReportingRejected`), which keeps a bad payload from taking the
1972
+ * rest of the operational-state cluster down with it.
1973
+ */
1974
+ isFaultReportingEnabled() {
1975
+ return (this.platform.platformConfig.enableMatterFaultReporting === true &&
1976
+ !this.faultReportingRejected);
1977
+ }
1978
+ /**
1979
+ * Map the robot's current condition onto a Matter ErrorStateStruct, or null
1980
+ * when nothing is wrong.
1981
+ *
1982
+ * `errorStateLabel` is deliberately never set. The Matter spec only allows
1983
+ * it for manufacturer-range IDs, and labels on standard IDs are exactly
1984
+ * what wedged Apple Home at "Connecting" in 1.4.40 — `errorStateDetails` is
1985
+ * the field that may carry free text alongside a standard ID.
1986
+ */
1987
+ buildOperationalError() {
1988
+ var _a, _b, _c;
1989
+ // The robot's own fault takes precedence over a dock consumable: if it is
1990
+ // stuck under the sofa, that is what the user needs to be told.
1991
+ const errorCode = this.getNumberStatus("error_code");
1992
+ if (errorCode !== null && errorCode !== 0) {
1993
+ return {
1994
+ errorStateId: (_a = ROBOROCK_ERROR_TO_MATTER.get(errorCode)) !== null && _a !== void 0 ? _a : RVC_ERROR_STATE.UNABLE_TO_COMPLETE_OPERATION,
1995
+ errorStateDetails: this.describeRoborockError(errorCode),
1996
+ };
1997
+ }
1998
+ const dockErrorStatus = this.getNumberStatus("dock_error_status");
1999
+ if (dockErrorStatus !== null && dockErrorStatus !== 0) {
2000
+ const mapped = ROBOROCK_DOCK_ERROR_TO_MATTER.get(dockErrorStatus);
2001
+ return {
2002
+ errorStateId: (_b = mapped === null || mapped === void 0 ? void 0 : mapped.errorStateId) !== null && _b !== void 0 ? _b : RVC_ERROR_STATE.UNABLE_TO_COMPLETE_OPERATION,
2003
+ errorStateDetails: (_c = mapped === null || mapped === void 0 ? void 0 : mapped.details) !== null && _c !== void 0 ? _c : `Dock error ${dockErrorStatus}`,
2004
+ };
2005
+ }
2006
+ // Onboard tank ran dry mid-mop. This flag is set by the robot only when
2007
+ // water is actually wanted, so it does not fire on a vacuum-only run.
2008
+ if (this.getNumberStatus("water_shortage_status") === 1) {
2009
+ return {
2010
+ errorStateId: RVC_ERROR_STATE.WATER_TANK_EMPTY,
2011
+ errorStateDetails: "Water tank empty",
2012
+ };
2013
+ }
2014
+ // Deliberately NOT reported as faults: `water_box_status` and
2015
+ // `water_box_carriage_status` being 0. A detached tank or mop pad is the
2016
+ // normal, correct configuration for a vacuum-only run, so treating them
2017
+ // as errors would put a permanent warning on every dry robot's tile.
2018
+ return null;
2019
+ }
2020
+ describeRoborockError(errorCode) {
2021
+ var _a, _b;
2022
+ const description = (_b = (_a = this.api).getErrorCodeDescription) === null || _b === void 0 ? void 0 : _b.call(_a, errorCode);
2023
+ return typeof description === "string" && description.length > 0
2024
+ ? description
2025
+ : `Roborock error ${errorCode}`;
2026
+ }
1779
2027
  /**
1780
2028
  * Battery percentage at which a docked robot switches from Charging to
1781
2029
  * Docked on the Matter tile. Defaults to 100 (charging until full); users
@@ -1913,7 +2161,20 @@ class RoborockMatterVacuumAccessory {
1913
2161
  }
1914
2162
  switch (operationalState) {
1915
2163
  case RVC_OPERATIONAL_STATE.ERROR:
1916
- return RVC_OPERATIONAL_STATE.STOPPED;
2164
+ // ERROR (3) is a member of even the basic advertised list, so
2165
+ // publishing it was always legal — it was downgraded to STOPPED
2166
+ // alongside the states that genuinely needed a gate, which is why a
2167
+ // robot stuck under the sofa has always read as "Ready" in Apple
2168
+ // Home. Report it for real once the user has opted in.
2169
+ //
2170
+ // Only Roborock states 9 (Charging Error) and 12 (In Error) reach
2171
+ // here, i.e. the robot's own claim that it has halted. Dock
2172
+ // consumables arrive through `dock_error_status` instead and never
2173
+ // touch the state, so a full waste-water tank does not make a robot
2174
+ // that can still vacuum look unstartable in Apple Home.
2175
+ return this.isFaultReportingEnabled()
2176
+ ? RVC_OPERATIONAL_STATE.ERROR
2177
+ : RVC_OPERATIONAL_STATE.STOPPED;
1917
2178
  case RVC_OPERATIONAL_STATE.SEEKING_CHARGER:
1918
2179
  return RVC_OPERATIONAL_STATE.STOPPED;
1919
2180
  case RVC_OPERATIONAL_STATE.EMPTYING_DUST_BIN: