homebridge-roborock-matter 3.10.2 → 3.11.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 +38 -12
- package/README.md +4 -2
- package/package.json +1 -1
- package/roborockLib/lib/b01Q7Adapter.js +92 -3
- package/roborockLib/lib/localConnector.js +5 -0
- package/roborockLib/lib/messageQueueHandler.js +10 -0
- package/roborockLib/lib/vacuum.js +15 -6
- package/roborockLib/roborockAPI.js +107 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.11.1
|
|
4
|
+
|
|
5
|
+
**The LAN port was open, the robot never answered on it, and the plugin kept asking for the life of the process — 10 seconds thrown away on every poll and every command.**
|
|
6
|
+
|
|
7
|
+
The reporter of [#8](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/8) runs Homebridge on a NAS in one VLAN and his Saros 10 in another. Port 58867 is reachable across that boundary, so the local client completes its TCP handshake and records `Local connect state: true` — and then every single request dies of silence 10 seconds later. `get_prop` on both startups, `app_segment_clean`, `app_pause`, `app_start`: all of them, every time.
|
|
8
|
+
|
|
9
|
+
The plugin only ever gave up on the LAN when the _connect_ failed. A socket that connected and then answered nothing was retried forever, which is why he saw a `get_prop` timeout at every restart and why his commands were slow before they worked at all. A successful handshake proves the port is reachable. It does not prove the robot is listening.
|
|
10
|
+
|
|
11
|
+
3 local timeouts in a row on a socket that still reports itself connected now write the LAN off for that robot and use the cloud instead, with 1 log line that says the port is open and the robot is not replying — the distinction that decides whether there is any point rewriting a firewall rule. Any local reply resets the count, so a single lost frame on a healthy network changes nothing; permanently exiling a robot to the cloud over one dropped packet would be worse than the bug being fixed.
|
|
12
|
+
|
|
13
|
+
The diagnostics report names this case separately from a failed connect, because those two look identical in a log and lead to opposite conclusions.
|
|
14
|
+
|
|
15
|
+
## 3.11.0
|
|
16
|
+
|
|
17
|
+
**A Q7 said it was between rooms 226 times during one clean. It was in the bedroom the whole time.**
|
|
18
|
+
|
|
19
|
+
I caught a run on my own robots and pulled the numbers rather than the impressions. One Q7, 47 minutes, 227 live-room fetches. 226 of them placed it at cell 22280,22100 — the same cell every time — while the room outlines on that map span 38 to 293. The pose behind it was exactly (1100, 1100), which is the same constant two other people's Q7s reported back in August. The remaining fetches resolved Stue, then Gang, then Soveværelse, in the order the robot actually moved.
|
|
20
|
+
|
|
21
|
+
So the robot does send a real position. It just serves a placeholder in between, and every one of those was being written up as "the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)". That sentence was wrong twice over: the robot was not between rooms, and there was nothing anyone could do about it. It also fed the miss counter, which is how a robot cleaning one bedroom produced "after 46 unresolved position(s)".
|
|
22
|
+
|
|
23
|
+
A position further outside the map than the map is wide is now recognised for what it is and named as a placeholder, once per run at a level you see and quietly thereafter. The test is geometry rather than the number 1100: the robot cannot be somewhere it has never mapped, which stays true if Roborock picks a different constant. A robot genuinely outside every outline — a doorway, a hallway nobody named, a strip the outlines do not cover — is still a real miss and still counts as one, because that is the case the miss line exists for. Classic S- and Q-series robots resolve every fetch and are untouched.
|
|
24
|
+
|
|
25
|
+
Two things fall out of it. The room still updates on a Q7, on the fetches that carry a true position; nothing about the tile changes. And a resolved room now prints the cell it resolved at, so a working position and a failing one can be compared in one log instead of across two field sessions — which is what this one cost.
|
|
26
|
+
|
|
27
|
+
**Also: 50 log lines a minute, per robot, that could never mean anything.** With debug on, every known status attribute produced `Skipping known get_status attribute without a Homebridge state object` on every poll. That check dates from this library's ioBroker origins, where the object it looks for exists; under Homebridge it never does, so the branch fired for every attribute forever and reported only that the plugin is not ioBroker. On my own server the log ring had shrunk to 90 minutes — the window you need when something real goes wrong. It is gone. An attribute nobody has mapped yet is still named once with its value, which is the half that carries information.
|
|
28
|
+
|
|
3
29
|
## 3.10.2
|
|
4
30
|
|
|
5
31
|
**You asked for vacuum-only, the robot mopped, and Apple Home showed vacuum anyway — because the plugin believed a command it had already logged that it lost.**
|
|
@@ -30,9 +56,9 @@ The cloud timeouts underneath this are not the plugin's to fix and are not new;
|
|
|
30
56
|
|
|
31
57
|
Every robot gets a forced full Matter write every 60 seconds. Its evidence line is written only when the rendered line would read differently from the last one — deliberately, since 3.10.0, so an idle robot does not fill the log with identical minutes.
|
|
32
58
|
|
|
33
|
-
The reporter of #7 was asked to check whether those lines were still appearing while his Apple Home tile was dead. It is the cheapest way to separate "the plugin stopped" from "the Matter session died underneath a healthy plugin". His robot was docked at 100 %, so every line rendered identically, so the last one was written at startup and none followed. He looked, correctly reported
|
|
59
|
+
The reporter of #7 was asked to check whether those lines were still appearing while his Apple Home tile was dead. It is the cheapest way to separate "the plugin stopped" from "the Matter session died underneath a healthy plugin". His robot was docked at 100 %, so every line rendered identically, so the last one was written at startup and none followed. He looked, correctly reported 11 minutes of nothing, and the answer was worth nothing — absence was the deduplication working, not evidence about whether anything ran. The question was unanswerable from the log at any level, which for a liveness signal is the wrong outcome.
|
|
34
60
|
|
|
35
|
-
A suppressed publish is now recorded at debug, naming the robot, the values and what triggered it. The info log stays exactly as quiet as before, and with plugin debug on,
|
|
61
|
+
A suppressed publish is now recorded at debug, naming the robot, the values and what triggered it. The info log stays exactly as quiet as before, and with plugin debug on, 11 minutes of a docked robot leaves 11 traces — so a gap in them means something.
|
|
36
62
|
|
|
37
63
|
## 3.10.0
|
|
38
64
|
|
|
@@ -98,7 +124,7 @@ It was not the cloud. Measured over 30,224 log lines covering 49 restarts: 92 re
|
|
|
98
124
|
|
|
99
125
|
A flaky connection does not look like that. It gives a varying number of attempts at varying times. One attempt, every time, only at startup, only on the cloud-only protocol is a race — and it was an ordering mistake in the startup sequence. The dedicated Q7 status loop was started at the end of device creation, and it polls immediately; the sequence did not wait for the MQTT session until after device creation had returned. A Q7 request is cloud-only by construction, so that first poll was rejected before anything reached the wire. The wait was already there, with a comment explaining this exact hazard for the two calls after it. The loop start had simply slipped in front of it.
|
|
100
126
|
|
|
101
|
-
The same event explains the other half, which had been observed
|
|
127
|
+
The same event explains the other half, which had been observed 14 times and never connected to it: for about 27 seconds after every restart, a Q7's tile in Apple Home showed `battery=100%, operationalState=0, runMode=0` — the snapshot taken at registration rather than the robot. That window is not a separate phenomenon. A refused attempt still stamps the request throttle, so the 15-second tick that followed fell inside the 25-second idle gap and was dropped, and the robot's real status did not arrive until the tick at 30 seconds. Measured median: 31 seconds.
|
|
102
128
|
|
|
103
129
|
Two changes, because the ordering fix alone leaves the hazard reachable — the wait resolves on a 10-second timeout whether or not the broker came up:
|
|
104
130
|
|
|
@@ -208,7 +234,7 @@ Also on that page: saves report failure instead of looking like they worked, the
|
|
|
208
234
|
|
|
209
235
|
**The log.** Two lines were removed as duplicates: the poll-profile notice was keyed per robot while its text is per model, so two robots of one model printed the same sentence twice, naming neither; and every room change was announced by both the library and the Matter layer with the same prefix. `Service started` was printed on the failure path — the `getHomeDetail` catch falls through to the same callback — directly under the stack trace saying it had failed; it now says what actually happened. That stack trace is gone too: a Roborock outage or a DNS blip is a warning with a sentence, not an error with a Node stack. `Starting adapter. This might take a few minutes` (it takes one second) and `Lets go!!!!!!!` are gone with the rest of the ioBroker vocabulary, `Adapter not inited. Command not executed.` now names the robot and says to try again in a few seconds, and a robot going offline is a warning that says what to check — with the matching "back online" line uncommented after who knows how long.
|
|
210
236
|
|
|
211
|
-
**
|
|
237
|
+
**14 more log lines were printing a raw 22-character duid to users.** `log-lines-name-the-robot` only inspected template literals written inside the logging call, so anything built into a variable or an `Error` first was invisible — it was checking 39 of 59 calls in one file alone. It now follows the three laundering channels as well, and everything it found is fixed.
|
|
212
238
|
|
|
213
239
|
**One resource leak.** `localConnector.js` opened its UDP discovery socket at module load, so requiring the file bound a socket a cloud-only install never uses, a second discovery pass attached a second set of handlers to it, and the first pass's `close()` left it unbindable for the next. It is now created per run and closed once. That also removes the "A worker process has failed to exit gracefully" warning the suite has printed for months, which was masking any real leak.
|
|
214
240
|
|
|
@@ -230,7 +256,7 @@ Verified red against 3.5.3: 3 of 29 fail, exactly the disk-read and fallback rul
|
|
|
230
256
|
|
|
231
257
|
3.5.0 mentioned pairing in one sentence, and the sentence was wrong: it assumed the bridge needed pairing, not enabling.
|
|
232
258
|
|
|
233
|
-
The startup log now answers which of three situations you are in, once per start, and warns rather than informs when HAP is off — an info line about a feature that cannot work reads like the
|
|
259
|
+
The startup log now answers which of three situations you are in, once per start, and warns rather than informs when HAP is off — an info line about a feature that cannot work reads like the 90 other info lines a start produces. The settings page shows the steps under the toggle when the feature is on, and the README and the setting's own description carry the same three: **Plugins → homebridge-roborock-matter → ⋮ → Child Bridge Config**, check **Enable HAP**, restart, then **Connect to HomeKit** on that screen and scan that QR code. All four surfaces name the two codes that look right and are not — the main Homebridge code, and the robot's Matter code, which covers the vacuum only.
|
|
234
260
|
|
|
235
261
|
`__tests__/the-switches-say-which-qr-code-to-scan.test.js` enumerates the rule over the surfaces, because the original failure was that only one surface mentioned pairing at all. Matching ignores markup, so `<strong>` and `**bold**` count as the same instruction. Verified red against 3.5.2: 26 of 26 fail.
|
|
236
262
|
|
|
@@ -379,14 +405,14 @@ The prep sequence sends up to three commands one after another, each with a two-
|
|
|
379
405
|
|
|
380
406
|
**Nothing in this release changes what the plugin does. It removes things that were never doing anything, and two of them were actively lying.**
|
|
381
407
|
|
|
382
|
-
- **Ten of the
|
|
408
|
+
- **Ten of the 11 shipped languages could never load.** `this.language` is only ever set from `options.language`; the sole production construction site passes none, the UI server hardcodes `"en"`, and no setting exposes the choice. So de, es, fr, it, nl, pl, pt, ru, uk and zh-cn — 78 KB of translations — were installed on every user's disk and read by nobody. They are gone. A test now enumerates the rule rather than the ten filenames: **a locale that ships must be selectable**, so adding one back fails until there is actually a way to pick it.
|
|
383
409
|
- **The README claimed 463 automated tests in one paragraph and 263 in another.** Both were wrong. Two hand-written numbers describing one fact will drift apart and neither gets corrected, because nothing checks them. There is one number now, and a test checks it against what the suite actually declares. It deliberately does not pin an exact figure — `test.each` expands at runtime and no static reader can know by how much — it pins the two things that went wrong: state it once, and keep it in a defensible band.
|
|
384
410
|
- **The publish log line still rendered `fault=…` from an attribute withdrawn in 3.4.1.** The branch was unreachable, and worse, it read as evidence the feature still existed. Removing it settles a real contradiction: `matter-fault-reporting.test.js` pinned that `operationalError` is never published, while `matter-publish-line-logs-every-change.test.js` hand-built one and asserted it rendered. Two tests disagreeing about whether a feature exists is worse than either answer.
|
|
385
411
|
- **An orphaned ioBroker map viewer and a MITM sniffing script** (`roborockLib/lib/map/`, `roborockLib/lib/sniffing/`) were excluded from the npm package rather than deleted — which is exactly how they survived unreviewed for so long. Ignored by the package, invisible in review, referenced by nothing.
|
|
386
412
|
- **Ten functions whose definition was their only occurrence in the entire tree** are gone: `getHomeID`, `decodeSniffedMessage`, `getConnector`, `updateDataExtraData`, `setupBasicObjects`, `getCleanSummary`, `resolve102Message`, `resolve301Message`, `BytesToInt`, `getErrorCodeDescription`, plus the unused `B01_REQUEST_DPS`/`B01_RESPONSE_DPS` constants and three exports nothing imported. `resolveLiveRoomId` went too — a one-line wrapper over `describeLiveRoomResolution` with no production callers, kept alive only by tests. Two ways to ask the same question is how one of them drifts.
|
|
387
413
|
- **`errorCodes` was NOT removed**, though a first pass called it dead. `deviceFeatures.js` still uses the table for its `error_code` state mapping. Worth recording: the check that catches this is grepping the whole tree, not reasoning about one file.
|
|
388
414
|
- **Two user-facing claims were false.** The `preferCloudForMatterCommands` setting promised to keep "the legacy HomeKit accessories unchanged", and a startup log line told users "The existing HomeKit accessory will continue to work." This fork removed every HAP accessory by design — there is nothing to fall back to. The log line now says what to actually do: enable Matter for the bridge.
|
|
389
|
-
- **ROADMAP.md was eight releases stale**, still titled for a different package, pointing at an `AGENTS.md` that has never existed in the tree, and listing HomeKit controls as delivered features
|
|
415
|
+
- **ROADMAP.md was eight releases stale**, still titled for a different package, pointing at an `AGENTS.md` that has never existed in the tree, and listing HomeKit controls as delivered features 13 lines above its own note that all HAP accessories were removed. Rewritten, with the pre-Matter-only entries labelled rather than deleted so the history stays readable.
|
|
390
416
|
|
|
391
417
|
## 3.4.12
|
|
392
418
|
|
|
@@ -399,7 +425,7 @@ The fix that added the two fields was made one level down from the gatekeeper, w
|
|
|
399
425
|
|
|
400
426
|
## 3.4.11
|
|
401
427
|
|
|
402
|
-
**Two docked Q7s flipped their Apple Home clean mode to "Vacuum" and back every
|
|
428
|
+
**Two docked Q7s flipped their Apple Home clean mode to "Vacuum" and back every 90 seconds, and the plugin was reporting a level it had never measured.** Caught in a log from a plugin author's own robots on 3.4.10: every battery tick produced a pair of publishes about a second apart, the first saying `cleanMode=0` and the second saying `cleanMode=6` — ten pairs in 14 minutes, on both robots, at the same battery value.
|
|
403
429
|
|
|
404
430
|
Mode 6 is "Max Vacuum", the level the robot is actually set to. Mode 0 is plain "Vacuum", a level nobody selected. When suction-level clean modes are announced (`enableFanPowerCleanModes`), the reported mode is derived from the robot's live fan power — but the derivation had no answer for "the fan power cannot be read right now". It fell through to the last Matter selection, which defaults to plain Vacuum, so a momentary gap in the reading was published as a definite statement about the robot's suction level.
|
|
405
431
|
|
|
@@ -411,7 +437,7 @@ This is the same class of defect as 3.4.6 and 3.4.7: reporting a value derived f
|
|
|
411
437
|
|
|
412
438
|
## 3.4.10
|
|
413
439
|
|
|
414
|
-
**The Q7 position that never resolved to a room is not a position at all.** 3.4.9 asked the two Q7s to report the range their room outlines occupy, and they answered: Garage sat in a map spanning cells 52–171 by 43–187, 1. Sal in one spanning 38–293 by 90–227. Back-computing through each map's own origin and resolution gives the same coordinate for both — exactly (1100.0, 1100.0), on two robots, two maps, and
|
|
440
|
+
**The Q7 position that never resolved to a room is not a position at all.** 3.4.9 asked the two Q7s to report the range their room outlines occupy, and they answered: Garage sat in a map spanning cells 52–171 by 43–187, 1. Sal in one spanning 38–293 by 90–227. Back-computing through each map's own origin and resolution gives the same coordinate for both — exactly (1100.0, 1100.0), on two robots, two maps, and 12 minutes of active cleaning. A number that identical is arithmetic, not a place a robot stood, which means live-room tracking on these models has never worked from that field.
|
|
415
441
|
|
|
416
442
|
- **The miss line now surveys the payload rather than asserting anything about it.** It prints the size of every top-level field and every scalar inside the small ones, keyed by field path. Two consecutive lines are then a diff: the value that changed while the robot was driving is the position, and the submessage that grew is the trail behind it.
|
|
417
443
|
- **Varints are surveyed, not just floats.** The pose message carries an `update` flag alongside its coordinates, so a float-only dump would have printed two plausible-looking numbers and hidden the field saying they were stale.
|
|
@@ -474,7 +500,7 @@ On a v1 robot the difference between "Vacuum" and "Vacuum and mop" **is** the wa
|
|
|
474
500
|
- **A field nobody has seen before still gets through.** If a robot starts sending something new after hours of uptime, it is reported on its own — quietening the noise must not also hide the signal, since these lines are the raw material for a model profile.
|
|
475
501
|
- **The line is now usable in a model report.** It names the robot and model, lists every field with its value, and says plainly that control, battery, rooms and state do not depend on them. Object values are serialised instead of arriving as `[object Object]`, which is what `cleaning_info` looked like in #8 — the one field where the shape was the interesting part.
|
|
476
502
|
- **Two startup tests no longer assert on the clock.** Both checked that per-robot probes run concurrently by timing them against a 180 ms budget — and on a quiet machine they finish in ~65 ms, so the assertion could only ever fail for a reason it was not testing. A scheduling hiccup was enough to fail a build with the concurrency perfectly correct. The check was also redundant: serialized probes give a peak concurrency of 1, which the neighbouring assertion already catches exactly. They now assert the property directly — every probe started before the first one finished — which holds on any machine under any load. Same defect 3.4.2 removed from the B01 full-chain simulation.
|
|
477
|
-
- **The log-naming rule from 3.3.2 was itself only half enumerated.** It listed three files by hand, and the files it left out held
|
|
503
|
+
- **The log-naming rule from 3.3.2 was itself only half enumerated.** It listed three files by hand, and the files it left out held 12 log lines still printing a bare 22-character duid — including `Device <duid> is offline.`, which is exactly the line someone quotes when asking why a robot dropped out. A hand-written file list is the same mistake as a hand-written line list, one level up. The rule now discovers the file list from the source tree, so a new file is covered the moment it exists, and all 12 lines now name the robot.
|
|
478
504
|
|
|
479
505
|
## 3.4.2
|
|
480
506
|
|
|
@@ -507,7 +533,7 @@ So the feature cost people their tile and never once delivered the thing it prom
|
|
|
507
533
|
|
|
508
534
|
## 3.3.2
|
|
509
535
|
|
|
510
|
-
- **Finished a job 3.3.1 only half did.** That release converted the B01 log lines to use the robot's name and missed
|
|
536
|
+
- **Finished a job 3.3.1 only half did.** That release converted the B01 log lines to use the robot's name and missed 11 others, including the live-room line for classic S/Q-series robots and the battery resync line — which then appeared in a field log directly above a line that did use the name: `Battery resync for 3tELc5hUekaTlOJEW3YetI` followed by `Matter publish for Garage`. Every user-visible log line now names the robot, and a test enumerates the rule rather than the instances, so the next line written cannot reintroduce it.
|
|
511
537
|
|
|
512
538
|
## 3.3.1
|
|
513
539
|
|
|
@@ -529,7 +555,7 @@ A robot that has stopped because it is wedged under the sofa has always looked e
|
|
|
529
555
|
- **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.
|
|
530
556
|
- **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.
|
|
531
557
|
- **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.
|
|
532
|
-
- **Diagnostics no longer truncate away the answer.** A Roborock status payload runs to about
|
|
558
|
+
- **Diagnostics no longer truncate away the answer.** A Roborock status payload runs to about 50 fields and the export kept the first 30 — which are largely housekeeping, while the 20 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.
|
|
533
559
|
|
|
534
560
|
## 3.2.0
|
|
535
561
|
|
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.
|
|
40
|
+
- 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 1134 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
|
|
|
@@ -79,6 +79,8 @@ Progress stays honest: a room is only shown as _completed_ once the robot was ac
|
|
|
79
79
|
|
|
80
80
|
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).
|
|
81
81
|
|
|
82
|
+
**A B01/Q7 robot answers most of those fetches with a placeholder rather than its position, and that is normal.** Measured over a 47-minute clean, 227 fetches: 226 returned exactly the same cell, far outside the map the robot itself had built, while the rest resolved real rooms in the order the robot moved through them. So the room still updates on a Q7 — just on the fetches that carry a true position, which arrive alongside the robot's own map uploads rather than on every poll. A placeholder is now named as one instead of being reported as "the robot is between rooms", and it is said once per run rather than every ten seconds. Classic S/Q-series robots are unaffected and resolve every fetch.
|
|
83
|
+
|
|
82
84
|
</details>
|
|
83
85
|
|
|
84
86
|
## Suction modes (optional)
|
|
@@ -215,7 +217,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
215
217
|
|
|
216
218
|
## Contributing
|
|
217
219
|
|
|
218
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
220
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1134 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.
|
|
219
221
|
|
|
220
222
|
## Support the project
|
|
221
223
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
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": {
|
|
@@ -641,11 +641,13 @@ function parseScMapLiveState(buffer) {
|
|
|
641
641
|
* whether the point-in-polygon test genuinely rejected the position. Those
|
|
642
642
|
* three call for three different fixes.
|
|
643
643
|
*
|
|
644
|
-
* @param {{head?: {minX: number, minY: number, resolution: number
|
|
644
|
+
* @param {{head?: {minX: number, minY: number, resolution: number,
|
|
645
|
+
* sizeX?: number, sizeY?: number} | null,
|
|
645
646
|
* pose?: {x: number, y: number} | null,
|
|
646
647
|
* roomChains?: Array<{roomId: number, points: Array<{x: number, y: number}>}>} | null} liveState
|
|
647
648
|
* @returns {{roomId: number | null,
|
|
648
|
-
* reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines"
|
|
649
|
+
* reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines"
|
|
650
|
+
* | "pose-outside-outlines" | "pose-placeholder",
|
|
649
651
|
* outlineCount: number,
|
|
650
652
|
* cell: {x: number, y: number} | null,
|
|
651
653
|
* outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
|
|
@@ -677,20 +679,107 @@ function describeLiveRoomResolution(liveState) {
|
|
|
677
679
|
|
|
678
680
|
for (const chain of chains) {
|
|
679
681
|
if (pointInPolygon(cellX, cellY, chain.points)) {
|
|
682
|
+
// The cell rides along on a hit too. Without it the log could say which
|
|
683
|
+
// attempts failed and with what position, but never what a SUCCEEDING
|
|
684
|
+
// position looked like — so the two could not be compared, and the
|
|
685
|
+
// measurement below took a second field session to make.
|
|
680
686
|
return { roomId: chain.roomId, reason: "resolved", outlineCount, cell };
|
|
681
687
|
}
|
|
682
688
|
}
|
|
683
689
|
|
|
690
|
+
const outlineBounds = outlineBoundingBox(chains);
|
|
691
|
+
|
|
692
|
+
// A pose that is not merely outside the rooms but nowhere near the map is a
|
|
693
|
+
// different thing, and calling both "outside the outlines" sent every
|
|
694
|
+
// investigation down the same wrong path for three weeks.
|
|
695
|
+
//
|
|
696
|
+
// Measured on a Q7 over a 47-minute clean, 227 fetches: 226 of them placed
|
|
697
|
+
// the robot at cell 22280,22100 — the same cell every time — while the room
|
|
698
|
+
// outlines spanned 38-293 x 90-227. The underlying pose was exactly
|
|
699
|
+
// (1100, 1100) in every one, the same constant two other Q7s reported in
|
|
700
|
+
// August. The remaining fetches resolved a real room in the right order, so
|
|
701
|
+
// the robot DOES send a true pose sometimes; it just serves a placeholder in
|
|
702
|
+
// between, and those are the fetches that must not be counted as the robot
|
|
703
|
+
// being between rooms.
|
|
704
|
+
//
|
|
705
|
+
// The test is deliberately about distance rather than about the value 1100:
|
|
706
|
+
// a placeholder is a position further outside the map than the map is wide,
|
|
707
|
+
// which no real robot can be, and which stays true if Roborock picks a
|
|
708
|
+
// different constant tomorrow.
|
|
709
|
+
if (isOffTheMap(cell, head, outlineBounds)) {
|
|
710
|
+
return {
|
|
711
|
+
roomId: null,
|
|
712
|
+
reason: "pose-placeholder",
|
|
713
|
+
outlineCount,
|
|
714
|
+
cell,
|
|
715
|
+
outlineBounds,
|
|
716
|
+
head: { minX: head.minX, minY: head.minY, resolution },
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
684
720
|
return {
|
|
685
721
|
roomId: null,
|
|
686
722
|
reason: "pose-outside-outlines",
|
|
687
723
|
outlineCount,
|
|
688
724
|
cell,
|
|
689
|
-
outlineBounds
|
|
725
|
+
outlineBounds,
|
|
690
726
|
head: { minX: head.minX, minY: head.minY, resolution },
|
|
691
727
|
};
|
|
692
728
|
}
|
|
693
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Whether a cell is outside the map raster itself.
|
|
732
|
+
*
|
|
733
|
+
* The map's own `sizeX`/`sizeY` is the right yardstick and the outline
|
|
734
|
+
* bounding box is not: outlines cover rooms, while the raster covers
|
|
735
|
+
* everywhere the robot has ever been. A robot standing in a doorway, in a
|
|
736
|
+
* hallway nobody named, or against a wall the outlines do not reach is
|
|
737
|
+
* legitimately outside every outline and inside the map — that is a real miss
|
|
738
|
+
* and must keep counting as one. Being outside the raster is not a position
|
|
739
|
+
* at all; the robot cannot be somewhere it has never mapped.
|
|
740
|
+
*
|
|
741
|
+
* One raster width of slack on each side, because the transform can put a
|
|
742
|
+
* genuine edge case a little past the boundary and this must not swallow a
|
|
743
|
+
* real coordinate bug. The measured placeholder sat at cell 22280 on a map
|
|
744
|
+
* 500 cells wide — 44 times out — so the margin costs nothing.
|
|
745
|
+
*
|
|
746
|
+
* Falls back to the outline bounds when a header carries no size, which is
|
|
747
|
+
* the only case where there is nothing better to compare against.
|
|
748
|
+
*
|
|
749
|
+
* @param {{x: number, y: number}} cell
|
|
750
|
+
* @param {{minX: number, minY: number, resolution: number,
|
|
751
|
+
* sizeX?: number, sizeY?: number}} head
|
|
752
|
+
* @param {{minX: number, minY: number, maxX: number, maxY: number} | null} outlineBounds
|
|
753
|
+
* @returns {boolean}
|
|
754
|
+
*/
|
|
755
|
+
function isOffTheMap(cell, head, outlineBounds) {
|
|
756
|
+
const sizeX = Number(head?.sizeX) || 0;
|
|
757
|
+
const sizeY = Number(head?.sizeY) || 0;
|
|
758
|
+
|
|
759
|
+
if (sizeX > 0 && sizeY > 0) {
|
|
760
|
+
return (
|
|
761
|
+
cell.x < -sizeX ||
|
|
762
|
+
cell.x > sizeX * 2 ||
|
|
763
|
+
cell.y < -sizeY ||
|
|
764
|
+
cell.y > sizeY * 2
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!outlineBounds) {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const spanX = Math.max(outlineBounds.maxX - outlineBounds.minX, 1);
|
|
773
|
+
const spanY = Math.max(outlineBounds.maxY - outlineBounds.minY, 1);
|
|
774
|
+
|
|
775
|
+
return (
|
|
776
|
+
cell.x < outlineBounds.minX - spanX * 4 ||
|
|
777
|
+
cell.x > outlineBounds.maxX + spanX * 4 ||
|
|
778
|
+
cell.y < outlineBounds.minY - spanY * 4 ||
|
|
779
|
+
cell.y > outlineBounds.maxY + spanY * 4
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
694
783
|
/**
|
|
695
784
|
* Bounding box of every room outline, in the same cell space the
|
|
696
785
|
* point-in-polygon test uses.
|
|
@@ -552,6 +552,11 @@ class localConnector {
|
|
|
552
552
|
const { resolve, timeout } = this.adapter.pendingRequests.get(id);
|
|
553
553
|
this.adapter.clearTimeout(timeout);
|
|
554
554
|
this.adapter.pendingRequests.delete(id);
|
|
555
|
+
// Proof that this socket is not mute, so any run of timeouts counted
|
|
556
|
+
// against it starts over.
|
|
557
|
+
if (this.adapter.noteLocalRequestSucceeded) {
|
|
558
|
+
this.adapter.noteLocalRequestSucceeded(duid);
|
|
559
|
+
}
|
|
555
560
|
resolve(result);
|
|
556
561
|
|
|
557
562
|
if (this.adapter.deviceNotify !== undefined) {
|
|
@@ -116,6 +116,7 @@ function getRequestTimeout(method, requestTimeoutMs) {
|
|
|
116
116
|
* @property {LoggerLike} log
|
|
117
117
|
* @property {(duid: string, update: TransportDiagnosticsUpdate) => Promise<void>} updateTransportDiagnostics
|
|
118
118
|
* @property {(duid: string) => Promise<boolean>} [ensureLocalConnection]
|
|
119
|
+
* @property {(duid: string, method?: string) => Promise<void>} [noteLocalRequestTimedOut]
|
|
119
120
|
* @property {(message: string, location: string, duid?: string) => void} catchError
|
|
120
121
|
* @property {(duid: string) => string} [describeDevice]
|
|
121
122
|
*/
|
|
@@ -347,6 +348,15 @@ class messageQueueHandler {
|
|
|
347
348
|
)
|
|
348
349
|
);
|
|
349
350
|
} else {
|
|
351
|
+
// A socket that keeps reporting itself connected while every
|
|
352
|
+
// request dies of silence is not a transport worth retrying
|
|
353
|
+
// forever. Fire-and-forget: the caller is owed its rejection now,
|
|
354
|
+
// not after the bookkeeping resolves.
|
|
355
|
+
if (this.adapter.noteLocalRequestTimedOut) {
|
|
356
|
+
Promise.resolve(
|
|
357
|
+
this.adapter.noteLocalRequestTimedOut(duid, method)
|
|
358
|
+
).catch(() => {});
|
|
359
|
+
}
|
|
350
360
|
reject(
|
|
351
361
|
new Error(
|
|
352
362
|
`Local request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds Local connect state: ${localConnectionState}`
|
|
@@ -567,17 +567,26 @@ class vacuum {
|
|
|
567
567
|
attribute
|
|
568
568
|
);
|
|
569
569
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
570
|
+
// A known attribute is skipped in silence. The line that used
|
|
571
|
+
// to be written here dated from this library's ioBroker origins,
|
|
572
|
+
// where `getObjectAsync` returns a state object that exists;
|
|
573
|
+
// under Homebridge it never exists, so the branch fired for
|
|
574
|
+
// EVERY known attribute on EVERY poll and said only that the
|
|
575
|
+
// plugin is not ioBroker. Measured on three robots with debug
|
|
576
|
+
// on: fifty lines a minute, and the log ring — the thing you
|
|
577
|
+
// need when something real goes wrong — held ninety minutes.
|
|
578
|
+
//
|
|
579
|
+
// The distinction below is the part that carries information
|
|
580
|
+
// and it is untouched: an attribute nobody has mapped yet is
|
|
581
|
+
// still reported once, by name and value.
|
|
582
|
+
if (
|
|
583
|
+
!isKnownStatusAttribute &&
|
|
575
584
|
this.rememberUnmappedStatusAttribute(duid, attribute)
|
|
576
585
|
) {
|
|
577
586
|
newlyUnmappedAttributes.push(
|
|
578
587
|
`${attribute}=${describeStatusValue(deviceStatus[0][attribute])}`
|
|
579
588
|
);
|
|
580
|
-
} else {
|
|
589
|
+
} else if (!isKnownStatusAttribute) {
|
|
581
590
|
this.adapter.log.debug(
|
|
582
591
|
`Unmapped get_status attribute ${attribute}=${describeStatusValue(deviceStatus[0][attribute])} for ${describeDevice(this.adapter, duid)}; already reported, not repeating.`
|
|
583
592
|
);
|
|
@@ -66,6 +66,19 @@ const CLOUD_ONLY_TRANSPORT_MARKERS = Object.freeze({
|
|
|
66
66
|
// for robots the plugin never attempts a LAN connection to.
|
|
67
67
|
const UNEXPLAINED_REMOTE_REASON = "remote-device";
|
|
68
68
|
|
|
69
|
+
// A local socket that completed its TCP handshake and then answered nothing.
|
|
70
|
+
// This is a different failure from a connect that failed, and conflating the
|
|
71
|
+
// two is what made it invisible: the port is reachable, so nothing looks
|
|
72
|
+
// broken, while every request still dies of silence at its timeout.
|
|
73
|
+
const LOCAL_MUTE_REMOTE_REASON = "local-socket-connected-but-mute";
|
|
74
|
+
|
|
75
|
+
// Consecutive local timeouts tolerated before the LAN is written off for a
|
|
76
|
+
// robot. One is noise — a single lost frame on a healthy network is ordinary,
|
|
77
|
+
// and exiling that robot to the cloud for it would be worse than the bug this
|
|
78
|
+
// bound fixes. Three in a row on a socket that keeps reporting itself
|
|
79
|
+
// connected is not noise.
|
|
80
|
+
const LOCAL_MUTE_TIMEOUT_LIMIT = 3;
|
|
81
|
+
|
|
69
82
|
// Minimum gap between live-room map fetch attempts while cleaning. The map
|
|
70
83
|
// payload is an order of magnitude heavier than get_status, so it rides a
|
|
71
84
|
// slower cadence than the active status polls — but 20s meant a robot could
|
|
@@ -92,6 +105,12 @@ const B01_LIVE_ROOM_MISS_REASONS = {
|
|
|
92
105
|
"the map payload carried no room outlines, so there was nothing to match the position against",
|
|
93
106
|
"pose-outside-outlines":
|
|
94
107
|
"the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)",
|
|
108
|
+
// Not a miss the user can do anything about, and not the robot being
|
|
109
|
+
// between rooms: the map payload carried a placeholder where the position
|
|
110
|
+
// should be. Measured at 226 of 227 fetches on a Q7 during a 47-minute
|
|
111
|
+
// clean, always the same cell. See describeLiveRoomResolution.
|
|
112
|
+
"pose-placeholder":
|
|
113
|
+
"the map payload carried a placeholder instead of the robot's position, so this fetch could not place it (the robot sends a real position only on some fetches)",
|
|
95
114
|
};
|
|
96
115
|
|
|
97
116
|
/**
|
|
@@ -324,6 +343,12 @@ class Roborock {
|
|
|
324
343
|
// see markDeviceRemote.
|
|
325
344
|
/** @type {Map<string, string>} */
|
|
326
345
|
this.remoteDeviceReasons = new Map();
|
|
346
|
+
// Consecutive local request timeouts per robot, counted only while the
|
|
347
|
+
// local client still reports itself connected. Reset by any local reply:
|
|
348
|
+
// the count has to mean "this socket answers nothing", not "this socket has
|
|
349
|
+
// ever timed out".
|
|
350
|
+
/** @type {Map<string, number>} */
|
|
351
|
+
this.localMuteTimeouts = new Map();
|
|
327
352
|
|
|
328
353
|
this.name = "roborock";
|
|
329
354
|
this.deviceNotify = null;
|
|
@@ -1415,6 +1440,8 @@ class Roborock {
|
|
|
1415
1440
|
"udp-broadcast-discovery": "UDP broadcast discovery found the vacuum",
|
|
1416
1441
|
"marked-remote-after-connect-failure":
|
|
1417
1442
|
"local TCP connection failed and the vacuum was marked remote",
|
|
1443
|
+
[LOCAL_MUTE_REMOTE_REASON]:
|
|
1444
|
+
"the local TCP socket connected but the vacuum answered nothing on it, so the cloud is used instead",
|
|
1418
1445
|
[b01Q7Adapter.B01_CLOUD_ONLY_REMOTE_REASON]:
|
|
1419
1446
|
"this model speaks only Roborock's cloud protocol, which has no LAN control surface",
|
|
1420
1447
|
};
|
|
@@ -2953,6 +2980,63 @@ class Roborock {
|
|
|
2953
2980
|
});
|
|
2954
2981
|
}
|
|
2955
2982
|
|
|
2983
|
+
/**
|
|
2984
|
+
* A local reply arrived, so the socket is not mute. Called from the one place
|
|
2985
|
+
* a local response lands, so the count measures the socket's current silence
|
|
2986
|
+
* rather than its history.
|
|
2987
|
+
*
|
|
2988
|
+
* @param {string} duid
|
|
2989
|
+
* @returns {void}
|
|
2990
|
+
*/
|
|
2991
|
+
noteLocalRequestSucceeded(duid) {
|
|
2992
|
+
if (!duid) {
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
this.localMuteTimeouts.delete(duid);
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
/**
|
|
3000
|
+
* A local request timed out on a socket that reported itself connected. After
|
|
3001
|
+
* LOCAL_MUTE_TIMEOUT_LIMIT of those in a row the LAN is written off for this
|
|
3002
|
+
* robot and the cloud is used instead, because the alternative is paying the
|
|
3003
|
+
* full timeout on every poll and every command for the life of the process.
|
|
3004
|
+
*
|
|
3005
|
+
* @param {string} duid
|
|
3006
|
+
* @param {string} [method] the request that died, for the log line
|
|
3007
|
+
* @returns {Promise<void>}
|
|
3008
|
+
*/
|
|
3009
|
+
async noteLocalRequestTimedOut(duid, method) {
|
|
3010
|
+
if (!duid) {
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
const failures = (this.localMuteTimeouts.get(duid) || 0) + 1;
|
|
3015
|
+
this.localMuteTimeouts.set(duid, failures);
|
|
3016
|
+
|
|
3017
|
+
if (failures < LOCAL_MUTE_TIMEOUT_LIMIT) {
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// Already written off: say it once, then stay quiet. The timeouts keep
|
|
3022
|
+
// coming until something upstream changes, and a warning per poll would
|
|
3023
|
+
// bury the one line that explains the switch.
|
|
3024
|
+
if (this.remoteDevices.has(duid)) {
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
this.log.warn(
|
|
3029
|
+
`The local connection to ${this.describeDevice(duid)} connected but answered nothing: ` +
|
|
3030
|
+
`${failures} requests in a row timed out${method ? ` (last: ${method})` : ""}. ` +
|
|
3031
|
+
`Using the Roborock cloud for this robot instead. The LAN port is reachable, ` +
|
|
3032
|
+
`so this is not a blocked port — the robot is not replying on it. On a segmented ` +
|
|
3033
|
+
`network, check that the reply path back to Homebridge is open, not just the ` +
|
|
3034
|
+
`outbound one.`
|
|
3035
|
+
);
|
|
3036
|
+
|
|
3037
|
+
await this.markDeviceRemote(duid, LOCAL_MUTE_REMOTE_REASON);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
2956
3040
|
/**
|
|
2957
3041
|
* Drops the remote marker and the reason that went with it.
|
|
2958
3042
|
*
|
|
@@ -4571,6 +4655,26 @@ class Roborock {
|
|
|
4571
4655
|
// actually sees, so "no room yet" is distinguishable from "the
|
|
4572
4656
|
// feature is broken" — and say WHICH of the four causes it was,
|
|
4573
4657
|
// because they call for different fixes.
|
|
4658
|
+
// A placeholder pose is not the robot failing to be in a room, so
|
|
4659
|
+
// it does not join the count that says how long the robot has gone
|
|
4660
|
+
// unplaced. Counting it produced "after 46 unresolved position(s)"
|
|
4661
|
+
// for a robot that had been cleaning one room the whole time, which
|
|
4662
|
+
// reads as a fault and is not one.
|
|
4663
|
+
//
|
|
4664
|
+
// It is said once per run at a level the user sees, with the numbers
|
|
4665
|
+
// that make it diagnosable, and then held at debug. In the field
|
|
4666
|
+
// that turns 226 info-and-debug lines per clean into one.
|
|
4667
|
+
if (resolution2.reason === "pose-placeholder") {
|
|
4668
|
+
const placeholderMessage = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}${describeOutlineBounds(resolution2)}).`;
|
|
4669
|
+
if (!liveState.placeholderReported) {
|
|
4670
|
+
liveState.placeholderReported = true;
|
|
4671
|
+
this.log.info(placeholderMessage);
|
|
4672
|
+
} else {
|
|
4673
|
+
this.log.debug(placeholderMessage);
|
|
4674
|
+
}
|
|
4675
|
+
return liveState.current;
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4574
4678
|
liveState.unresolvedPoseCount =
|
|
4575
4679
|
(liveState.unresolvedPoseCount || 0) + 1;
|
|
4576
4680
|
const message = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (attempt ${liveState.unresolvedPoseCount} this run, ${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}${describeOutlineBounds(resolution2)}${describeRawMapFields(parsed)}).`;
|
|
@@ -4601,7 +4705,7 @@ class Roborock {
|
|
|
4601
4705
|
|
|
4602
4706
|
if (!previous || previous.segmentId !== roomId) {
|
|
4603
4707
|
this.log.info(
|
|
4604
|
-
`Live room for ${this.describeDevice(duid)}: ${roomName} (${roomId})${previous ? ` — was ${previous.roomName} (${previous.segmentId})` : ""}${missedBeforeThis > 0 ? ` (after ${missedBeforeThis} unresolved position(s))` : ""}.`
|
|
4708
|
+
`Live room for ${this.describeDevice(duid)}: ${roomName} (${roomId})${previous ? ` — was ${previous.roomName} (${previous.segmentId})` : ""}${missedBeforeThis > 0 ? ` (after ${missedBeforeThis} unresolved position(s))` : ""}${resolution2.cell ? ` [position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}]` : ""}.`
|
|
4605
4709
|
);
|
|
4606
4710
|
const lastV1Status = this._b01StatusState?.get(duid)?.lastV1Status;
|
|
4607
4711
|
if (this.deviceNotify && lastV1Status) {
|
|
@@ -4934,6 +5038,8 @@ module.exports = {
|
|
|
4934
5038
|
Roborock,
|
|
4935
5039
|
CLOUD_ONLY_TRANSPORT_MARKERS,
|
|
4936
5040
|
UNEXPLAINED_REMOTE_REASON,
|
|
5041
|
+
LOCAL_MUTE_REMOTE_REASON,
|
|
5042
|
+
LOCAL_MUTE_TIMEOUT_LIMIT,
|
|
4937
5043
|
};
|
|
4938
5044
|
|
|
4939
5045
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|