homebridge-roborock-matter 3.31.0 → 3.32.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,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.32.0
|
|
4
|
+
|
|
5
|
+
**The give-up register had never counted a single poll failure. Not one, in two releases that were built around it.**
|
|
6
|
+
|
|
7
|
+
### What I shipped twice and never verified
|
|
8
|
+
|
|
9
|
+
3.30.0 introduced a register that stops asking a robot a question it never answers. 3.31.0 routed three more polls into it and fixed how it attributed blame. Both releases described it — in the changelog, and in the log line you read — as the thing that ends the flood of repeated timeouts, naming the seven methods from #22 and #24 that motivated it.
|
|
10
|
+
|
|
11
|
+
It could not have counted any of them. `pollParameter` decided the outcome from its own `try/catch`:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
try {
|
|
15
|
+
const answer = await vacuum.getParameter(duid, method);
|
|
16
|
+
this.noteMethodAnswered(duid, method); // ran on EVERY poll
|
|
17
|
+
return answer;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
this.noteMethodUnanswered(duid, method, error); // unreachable
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`vacuum.getParameter` swallows its own errors. Its catch calls `catchError`, which only logs, and the function resolves `undefined`. So the catch here never ran. Every timeout looked like a success, and `noteMethodAnswered` **reset the counter on each failed poll**.
|
|
25
|
+
|
|
26
|
+
Measured on the real classes before the fix: 20 consecutive timeouts produced **0 entries** in the register. And a robot that had somehow been left alone was told `answers get_room_mapping again; it is back in the normal poll cycle` on the retry that had just timed out.
|
|
27
|
+
|
|
28
|
+
The only methods it ever governed were the three map requests, because those go through `sendRequest` directly rather than `getParameter`. Everything else — `get_room_mapping`, `get_multi_maps_list`, `get_consumable`, `get_carpet_mode`, `get_carpet_clean_mode`, `get_water_box_custom_mode`, `get_timer` — was untouched. 3.31.0's "three polls were bypassing the register" fix wired them to nothing.
|
|
29
|
+
|
|
30
|
+
### The register now watches the wire
|
|
31
|
+
|
|
32
|
+
A request is answered or it is not, and the only place that knows is the message layer. So that is where the register is fed from: `messageQueueHandler` reports a reply when one arrives and a timeout when one does not. `pollParameter` no longer guesses at the outcome — it just declares which requests it is entitled to skip.
|
|
33
|
+
|
|
34
|
+
That declaration matters, because the message layer sees **every** request, including `get_status` and every command. A robot/method pair is counted only after a caller that can skip it has claimed it, so the two things this register must never touch cannot be reached by construction. There are tests that fire a hundred `get_status` timeouts and a hundred command timeouts and assert nothing closes.
|
|
35
|
+
|
|
36
|
+
Measured after: 200 polls at a silent method send **6 requests**.
|
|
37
|
+
|
|
38
|
+
### And the transport exclusion was dead code again
|
|
39
|
+
|
|
40
|
+
3.30.0's version looked for `EAI_AGAIN`, `offline` and friends — words the timeout message never contains. 3.31.0 replaced it with a regex for `MQTT connection state: false`, and I wrote a checklist entry about building test fixtures from real strings. That regex cannot match either: a down link is rejected **earlier**, with a refusal that has no "timed out after" in it, so the timeout arm is only ever reached with the flag reading `true`. Two releases, two dead gates, two green tests built from strings the code cannot produce.
|
|
41
|
+
|
|
42
|
+
The timeout now carries what it knows as data — `unansweredRequest` and `transportWasUp`, the latter read at **rejection** time rather than copied before the send, because a link that dies mid-flight is the entire case the exclusion exists for. The register reads the fields. No prose is parsed.
|
|
43
|
+
|
|
44
|
+
### The measurement I hid behind a log level
|
|
45
|
+
|
|
46
|
+
3.31.0 added a line for every discarded protocol-301 map frame, to answer why `get_map_v1` times out forever on a robot that answers everything else. I put it at **debug**, which is off by default. On my own server it produced nothing; a user in #24 went looking and found nothing. We both read that as evidence. It was not evidence — it was a log level.
|
|
47
|
+
|
|
48
|
+
Discarded frames are now counted per robot, and the count rides along in the give-up message, at info level, where nobody has to know to go looking:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Stueetage has not answered get_map_v1 6 times in a row, so the plugin stops
|
|
52
|
+
asking for about 6 hour(s)… No reply frames for this robot were received and
|
|
53
|
+
discarded, so the reply is not arriving at all rather than being lost on this
|
|
54
|
+
side.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or, if it is our fault:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
…NOTE: 6 map reply frame(s) for this robot were received and then discarded by
|
|
61
|
+
the plugin (addressed-elsewhere: 6), so the robot IS answering and this side is
|
|
62
|
+
throwing it away. Please report this — it is a bug here, not on your robot.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The same figure is in the diagnostic report. Either way the question gets answered by a log you were going to send anyway.
|
|
66
|
+
|
|
67
|
+
### Field notes from 3.31.0
|
|
68
|
+
|
|
69
|
+
The give-up rule did fire correctly on my own a70: `get_map_v1`, 6 in a row, paused for 6 hours — where the same request used to fail 95 and 225 times. That part worked, because the map path was the one path the register could actually see.
|
|
70
|
+
|
|
71
|
+
2045 tests, 14 of them new and all 14 red against 3.31.0.
|
|
72
|
+
|
|
3
73
|
## 3.31.0
|
|
4
74
|
|
|
5
75
|
**Six bugs, three of them mine from last week. And the oldest unexplained failure in this project finally has an instrument pointed at it.**
|
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. 2045 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
|
|
|
@@ -264,7 +264,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
264
264
|
|
|
265
265
|
## Contributing
|
|
266
266
|
|
|
267
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
267
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 2045 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.
|
|
268
268
|
|
|
269
269
|
## Support the project
|
|
270
270
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.32.0",
|
|
4
4
|
"description": "The most complete Roborock plugin for Apple Home. Supports the entire Roborock lineup — from the classic S-series to the new 2025 Q7 series that no other plugin can control. Sign in with your Roborock account and get native start/stop, room cleaning, suction levels, battery, and live 'cleaning in the kitchen' room tracking. Verified by Homebridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -191,6 +191,23 @@ function describeCloudSilence(adapter, duid, receiptsAtSend) {
|
|
|
191
191
|
* @property {boolean} [cloudOnlyMode]
|
|
192
192
|
*/
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* A request that was sent and drew no reply, carrying what the transport knew
|
|
196
|
+
* at the moment it gave up. The give-up register reads these fields instead of
|
|
197
|
+
* parsing the message text — twice now a prose-matching rule has turned out to
|
|
198
|
+
* be dead code against a string the plugin cannot produce.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} message
|
|
201
|
+
* @param {boolean} transportWasUp whether the link was up AT REJECTION TIME
|
|
202
|
+
* @returns {Error & {unansweredRequest: boolean, transportWasUp: boolean}}
|
|
203
|
+
*/
|
|
204
|
+
function unansweredRequestError(message, transportWasUp) {
|
|
205
|
+
return Object.assign(new Error(message), {
|
|
206
|
+
unansweredRequest: true,
|
|
207
|
+
transportWasUp,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
194
211
|
/**
|
|
195
212
|
* @typedef {Object} MessageQueueAdapter
|
|
196
213
|
* @property {RoborockConfig} [config]
|
|
@@ -208,6 +225,8 @@ function describeCloudSilence(adapter, duid, receiptsAtSend) {
|
|
|
208
225
|
* @property {(duid: string, update: TransportDiagnosticsUpdate) => Promise<void>} updateTransportDiagnostics
|
|
209
226
|
* @property {(duid: string) => Promise<boolean>} [ensureLocalConnection]
|
|
210
227
|
* @property {(duid: string, method?: string) => Promise<void>} [noteLocalRequestTimedOut]
|
|
228
|
+
* @property {(duid: string, method: string) => void} [noteRequestAnswered]
|
|
229
|
+
* @property {(duid: string, method: string, error: unknown) => void} [noteRequestUnanswered]
|
|
211
230
|
* @property {(duid: string) => number} [getCloudMessageReceiptCount] How many
|
|
212
231
|
* decoded MQTT messages have been attributed to this robot since startup.
|
|
213
232
|
* Optional so an adapter that cannot count them keeps the old timeout text.
|
|
@@ -541,11 +560,20 @@ class messageQueueHandler {
|
|
|
541
560
|
this.adapter.pendingRequests.delete(messageID);
|
|
542
561
|
this.adapter.localConnector.clearChunkBuffer(duid);
|
|
543
562
|
if (useCloudConnection) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
563
|
+
// The link state READ NOW, not the copy taken before the send.
|
|
564
|
+
// A link that died mid-flight is the entire case the give-up
|
|
565
|
+
// register's transport exclusion exists for, and both previous
|
|
566
|
+
// attempts at that exclusion were dead code because they read a
|
|
567
|
+
// value captured before the request even went out.
|
|
568
|
+
const transportWasUp = Boolean(
|
|
569
|
+
this.adapter.rr_mqtt_connector?.isConnected?.()
|
|
548
570
|
);
|
|
571
|
+
const error = unansweredRequestError(
|
|
572
|
+
`Cloud request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds. MQTT connection state: ${transportWasUp}${describeCloudSilence(this.adapter, duid, receiptsAtSend)}`,
|
|
573
|
+
transportWasUp
|
|
574
|
+
);
|
|
575
|
+
this.adapter.noteRequestUnanswered?.(duid, method, error);
|
|
576
|
+
reject(error);
|
|
549
577
|
} else {
|
|
550
578
|
// A socket that keeps reporting itself connected while every
|
|
551
579
|
// request dies of silence is not a transport worth retrying
|
|
@@ -556,11 +584,15 @@ class messageQueueHandler {
|
|
|
556
584
|
this.adapter.noteLocalRequestTimedOut(duid, method)
|
|
557
585
|
).catch(() => {});
|
|
558
586
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
587
|
+
const transportWasUp = Boolean(
|
|
588
|
+
this.adapter.localConnector?.isConnected?.(duid)
|
|
589
|
+
);
|
|
590
|
+
const error = unansweredRequestError(
|
|
591
|
+
`Local request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds Local connect state: ${transportWasUp}`,
|
|
592
|
+
transportWasUp
|
|
563
593
|
);
|
|
594
|
+
this.adapter.noteRequestUnanswered?.(duid, method, error);
|
|
595
|
+
reject(error);
|
|
564
596
|
}
|
|
565
597
|
}, requestTimeout);
|
|
566
598
|
|
|
@@ -571,7 +603,18 @@ class messageQueueHandler {
|
|
|
571
603
|
// reply IS the result). It used to guess by comparing the result to
|
|
572
604
|
// the string "ok", which silently never matched.
|
|
573
605
|
this.adapter.pendingRequests.set(messageID, {
|
|
574
|
-
|
|
606
|
+
// Wrapped so the give-up register learns of an answer HERE, in the
|
|
607
|
+
// one place that knows a reply arrived. Until 3.32.0 the register
|
|
608
|
+
// was told by `pollParameter`, whose try/catch never fired:
|
|
609
|
+
// `vacuum.getParameter` swallows its own errors (it calls
|
|
610
|
+
// catchError, which only logs) and resolves `undefined`. So every
|
|
611
|
+
// poll looked like a success, the failure branch was unreachable,
|
|
612
|
+
// and the register never counted a single one — including all
|
|
613
|
+
// seven methods in #22/#24 that it was built for.
|
|
614
|
+
resolve: (value) => {
|
|
615
|
+
this.adapter.noteRequestAnswered?.(duid, method);
|
|
616
|
+
resolve(value);
|
|
617
|
+
},
|
|
575
618
|
reject,
|
|
576
619
|
timeout,
|
|
577
620
|
secure,
|
|
@@ -51,6 +51,42 @@ let rriot;
|
|
|
51
51
|
// silent map outage with no error anywhere.
|
|
52
52
|
const photoBuffers = new Map();
|
|
53
53
|
|
|
54
|
+
// How many protocol-301 frames have been discarded per robot, and why.
|
|
55
|
+
//
|
|
56
|
+
// 3.31.0 added a log line for each drop — at DEBUG level, which is off by
|
|
57
|
+
// default. So the measurement that was meant to answer this project's oldest
|
|
58
|
+
// question (why `get_map_v1` times out forever on a robot that answers
|
|
59
|
+
// everything else) could never appear in a log anyone would send. That is the
|
|
60
|
+
// same mistake as the silent `return` it replaced: the information existed
|
|
61
|
+
// and nobody could reach it.
|
|
62
|
+
//
|
|
63
|
+
// Counting it instead means the number rides along in the give-up message and
|
|
64
|
+
// the diagnostic report, both of which are INFO level and both of which users
|
|
65
|
+
// already paste.
|
|
66
|
+
const droppedFrames = new Map();
|
|
67
|
+
|
|
68
|
+
function noteDroppedFrame(duid, reason) {
|
|
69
|
+
let entry = droppedFrames.get(duid);
|
|
70
|
+
if (!entry) {
|
|
71
|
+
entry = { total: 0, byReason: {} };
|
|
72
|
+
droppedFrames.set(duid, entry);
|
|
73
|
+
}
|
|
74
|
+
entry.total += 1;
|
|
75
|
+
entry.byReason[reason] = (entry.byReason[reason] || 0) + 1;
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Protocol-301 frames discarded for one robot, for the give-up message and
|
|
81
|
+
* the diagnostic report.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} duid
|
|
84
|
+
* @returns {{total: number, byReason: Record<string, number>}}
|
|
85
|
+
*/
|
|
86
|
+
function describeDroppedFrames(duid) {
|
|
87
|
+
return droppedFrames.get(duid) || { total: 0, byReason: {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
function photoBufferFor(duid) {
|
|
55
91
|
let entry = photoBuffers.get(duid);
|
|
56
92
|
if (!entry) {
|
|
@@ -564,6 +600,7 @@ class roborock_mqtt_connector {
|
|
|
564
600
|
// Whether that IS the cause is not settled here — it is
|
|
565
601
|
// measured, because from 3.31.0 the drop says so.
|
|
566
602
|
if (!String(data2.endpoint || "").startsWith(endpoint)) {
|
|
603
|
+
noteDroppedFrame(duid, "addressed-elsewhere");
|
|
567
604
|
this.adapter.log.debug(
|
|
568
605
|
`Dropped a protocol 301 message for ${duid}: it is addressed to endpoint '${data2.endpoint}', and this plugin's endpoint is '${endpoint}'. The reply was received and decrypted but is not ours, so the request that is waiting will time out. If you are seeing map or live-room requests time out on a robot that answers everything else, this line is the reason — please report it.`
|
|
569
606
|
);
|
|
@@ -584,6 +621,7 @@ class roborock_mqtt_connector {
|
|
|
584
621
|
// this.adapter.log.debug("raw 301: " + decrypted);
|
|
585
622
|
|
|
586
623
|
if (!this.adapter.pendingRequests.has(data2.id)) {
|
|
624
|
+
noteDroppedFrame(duid, "no-request-waiting");
|
|
587
625
|
// The other silent drop on this path. An unsolicited map push
|
|
588
626
|
// lands here legitimately, but so does a reply whose id we
|
|
589
627
|
// failed to match — and the waiting request then times out
|
|
@@ -839,6 +877,7 @@ function resolveB01PendingResponse(adapter, duid, dps) {
|
|
|
839
877
|
}
|
|
840
878
|
|
|
841
879
|
module.exports = {
|
|
880
|
+
describeDroppedFrames,
|
|
842
881
|
resolveB01PendingResponse,
|
|
843
882
|
roborock_mqtt_connector,
|
|
844
883
|
parseProtocol301Header,
|
|
@@ -67,6 +67,28 @@ const COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
|
67
67
|
* @returns {boolean}
|
|
68
68
|
*/
|
|
69
69
|
function isUnansweredRequest(error) {
|
|
70
|
+
// THE STRUCTURED ANSWER FIRST, because parsing prose has now been wrong
|
|
71
|
+
// twice in two releases.
|
|
72
|
+
//
|
|
73
|
+
// 3.30.0 excluded transport failures by looking for `EAI_AGAIN`, `offline`
|
|
74
|
+
// and friends — words the timeout message never contains. 3.31.0 replaced
|
|
75
|
+
// that with a regex for `MQTT connection state: false`, which cannot occur
|
|
76
|
+
// either: messageQueueHandler rejects a down link EARLIER, with a refusal
|
|
77
|
+
// that has no "timed out after" in it at all, so the timeout arm is only
|
|
78
|
+
// ever reached with the flag reading `true`. Both gates were dead code, and
|
|
79
|
+
// both had a green test built from a hand-written string the code cannot
|
|
80
|
+
// produce.
|
|
81
|
+
//
|
|
82
|
+
// So the timeout now carries what it knows as data. `transportWasUp` is
|
|
83
|
+
// read at REJECTION time, not at send time — a link that died mid-flight is
|
|
84
|
+
// the whole case the exclusion exists for.
|
|
85
|
+
if (error && typeof error === "object" && "unansweredRequest" in error) {
|
|
86
|
+
if (error.unansweredRequest !== true) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return error.transportWasUp !== false;
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
71
93
|
if (!/timed out after/i.test(message)) {
|
|
72
94
|
return false;
|
|
@@ -118,6 +140,8 @@ class UnansweredMethodBreaker {
|
|
|
118
140
|
this.now = options.now ?? (() => Date.now());
|
|
119
141
|
/** @type {Map<string, {failures: number, openedAt: number, retryAt: number}>} */
|
|
120
142
|
this.entries = new Map();
|
|
143
|
+
/** @type {Set<string>} pairs a skipping caller has claimed; see govern() */
|
|
144
|
+
this.governed = new Set();
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
/**
|
|
@@ -129,6 +153,37 @@ class UnansweredMethodBreaker {
|
|
|
129
153
|
return `${duid}:${method}`;
|
|
130
154
|
}
|
|
131
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Declare that a request is one this register governs.
|
|
158
|
+
*
|
|
159
|
+
* WHY THIS EXISTS. From 3.32.0 the register is fed by the message layer —
|
|
160
|
+
* the only place that actually knows whether a request was answered — and
|
|
161
|
+
* that layer sees EVERY request, including `get_status` and every command.
|
|
162
|
+
* Counting those would break the promise this class is built on: the tile
|
|
163
|
+
* lives on `get_status`, and a button the user just pressed must always be
|
|
164
|
+
* sent.
|
|
165
|
+
*
|
|
166
|
+
* So a (robot, method) pair is counted only after the caller that CAN skip
|
|
167
|
+
* it has said so. `pollParameter` and the two live-room fetches call this
|
|
168
|
+
* before sending; nothing else does, so nothing else can ever be closed.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} duid
|
|
171
|
+
* @param {string} method
|
|
172
|
+
* @returns {void}
|
|
173
|
+
*/
|
|
174
|
+
govern(duid, method) {
|
|
175
|
+
this.governed.add(this.key(duid, method));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} duid
|
|
180
|
+
* @param {string} method
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
isGoverned(duid, method) {
|
|
184
|
+
return this.governed.has(this.key(duid, method));
|
|
185
|
+
}
|
|
186
|
+
|
|
132
187
|
/**
|
|
133
188
|
* Whether this method should be skipped right now.
|
|
134
189
|
*
|
|
@@ -183,6 +238,10 @@ class UnansweredMethodBreaker {
|
|
|
183
238
|
* @returns {{counted: boolean, failures: number, opened: boolean, retryInMs: number}}
|
|
184
239
|
*/
|
|
185
240
|
recordFailure(duid, method, error) {
|
|
241
|
+
// Only requests a skipping caller has claimed. See govern().
|
|
242
|
+
if (!this.isGoverned(duid, method)) {
|
|
243
|
+
return { counted: false, failures: 0, opened: false, retryInMs: 0 };
|
|
244
|
+
}
|
|
186
245
|
if (!isUnansweredRequest(error)) {
|
|
187
246
|
return { counted: false, failures: 0, opened: false, retryInMs: 0 };
|
|
188
247
|
}
|
|
@@ -26,6 +26,7 @@ const {
|
|
|
26
26
|
parseCloudSceneSchedules,
|
|
27
27
|
} = require("./lib/parseCloudSceneSchedules");
|
|
28
28
|
const b01Q7Adapter = require("./lib/b01Q7Adapter");
|
|
29
|
+
const { describeDroppedFrames } = require("./lib/roborock_mqtt_connector");
|
|
29
30
|
const { UnansweredMethodBreaker } = require("./lib/unansweredMethodBreaker");
|
|
30
31
|
|
|
31
32
|
// v1 states in which the robot is actively doing something and state
|
|
@@ -3365,14 +3366,48 @@ class Roborock {
|
|
|
3365
3366
|
return undefined;
|
|
3366
3367
|
}
|
|
3367
3368
|
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3369
|
+
// Claim this request, then just send it.
|
|
3370
|
+
//
|
|
3371
|
+
// Until 3.32.0 this wrapped the call in a try/catch and reported the
|
|
3372
|
+
// outcome itself. That never worked: `vacuum.getParameter` swallows its
|
|
3373
|
+
// own errors — its catch calls `catchError`, which only logs — and
|
|
3374
|
+
// resolves `undefined`. So the catch here was unreachable, every timeout
|
|
3375
|
+
// looked like a success, and `noteMethodAnswered` actively RESET the
|
|
3376
|
+
// counter on each failed poll. Measured: 20 consecutive timeouts produced
|
|
3377
|
+
// 0 entries in the register, and a robot that had finally been left alone
|
|
3378
|
+
// was told "answers get_room_mapping again" on the retry that timed out.
|
|
3379
|
+
//
|
|
3380
|
+
// The register is now fed by messageQueueHandler, the only place that
|
|
3381
|
+
// knows whether a reply arrived. All this has to do is say which requests
|
|
3382
|
+
// it is entitled to skip.
|
|
3383
|
+
this.unansweredMethods.govern(duid, method);
|
|
3384
|
+
return vacuum.getParameter(duid, method);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
/**
|
|
3388
|
+
* The message layer saw a reply. Called for EVERY request, including
|
|
3389
|
+
* `get_status` and every command — the register ignores any pair no
|
|
3390
|
+
* skipping caller has claimed with `govern()`, so those can never be closed.
|
|
3391
|
+
*
|
|
3392
|
+
* @param {string} duid
|
|
3393
|
+
* @param {string} method
|
|
3394
|
+
* @returns {void}
|
|
3395
|
+
*/
|
|
3396
|
+
noteRequestAnswered(duid, method) {
|
|
3397
|
+
this.noteMethodAnswered(duid, method);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
/**
|
|
3401
|
+
* The message layer saw a request die on its timer. Same filtering: only a
|
|
3402
|
+
* claimed pair is counted.
|
|
3403
|
+
*
|
|
3404
|
+
* @param {string} duid
|
|
3405
|
+
* @param {string} method
|
|
3406
|
+
* @param {unknown} error
|
|
3407
|
+
* @returns {void}
|
|
3408
|
+
*/
|
|
3409
|
+
noteRequestUnanswered(duid, method, error) {
|
|
3410
|
+
this.noteMethodUnanswered(duid, method, error);
|
|
3376
3411
|
}
|
|
3377
3412
|
|
|
3378
3413
|
/**
|
|
@@ -3420,9 +3455,37 @@ class Roborock {
|
|
|
3420
3455
|
failures: entry.failures,
|
|
3421
3456
|
retryInMinutes: Math.round(entry.retryInMs / 60000),
|
|
3422
3457
|
})),
|
|
3458
|
+
// Same question, in the report people paste: was the reply discarded
|
|
3459
|
+
// here, or did it never arrive?
|
|
3460
|
+
discardedReplyFrames: describeDroppedFrames(duid),
|
|
3423
3461
|
});
|
|
3462
|
+
// THE DIAGNOSIS, IN THE LINE PEOPLE ALREADY PASTE.
|
|
3463
|
+
//
|
|
3464
|
+
// 3.31.0 put the answer to "is the robot silent, or are we discarding its
|
|
3465
|
+
// reply?" behind a DEBUG log line. Debug is off by default, so the
|
|
3466
|
+
// measurement was invisible — on my own server it produced exactly
|
|
3467
|
+
// nothing, and a user in #24 went looking for it and found nothing
|
|
3468
|
+
// either. Both of us read that as evidence. It was not evidence; it was a
|
|
3469
|
+
// log level.
|
|
3470
|
+
//
|
|
3471
|
+
// A map reply arrives as a protocol-301 frame, and a frame this plugin
|
|
3472
|
+
// discards leaves the request to die on its timer — indistinguishable
|
|
3473
|
+
// from a robot that never answered. So say which one it was, here, at
|
|
3474
|
+
// info level, where the number cannot be missed.
|
|
3475
|
+
const dropped = describeDroppedFrames(duid);
|
|
3476
|
+
const verdict =
|
|
3477
|
+
dropped.total > 0
|
|
3478
|
+
? ` NOTE: ${dropped.total} map reply frame(s) for this robot were received and then discarded by the plugin (${Object.entries(
|
|
3479
|
+
dropped.byReason
|
|
3480
|
+
)
|
|
3481
|
+
.map(([reason, count]) => `${reason}: ${count}`)
|
|
3482
|
+
.join(
|
|
3483
|
+
", "
|
|
3484
|
+
)}), so the robot IS answering and this side is throwing it away. Please report this — it is a bug here, not on your robot.`
|
|
3485
|
+
: " No reply frames for this robot were received and discarded, so the reply is not arriving at all rather than being lost on this side.";
|
|
3486
|
+
|
|
3424
3487
|
this.log.info(
|
|
3425
|
-
`${this.describeDevice(duid)} has not answered ${method} ${outcome.failures} times in a row, so the plugin stops asking for about ${hours} hour(s) and then tries once more. Everything else about this robot is unaffected, and one answer puts it straight back in the normal cycle. This is the robot or the Roborock cloud declining to reply, not a connection failure — those are reported separately
|
|
3488
|
+
`${this.describeDevice(duid)} has not answered ${method} ${outcome.failures} times in a row, so the plugin stops asking for about ${hours} hour(s) and then tries once more. Everything else about this robot is unaffected, and one answer puts it straight back in the normal cycle. This is the robot or the Roborock cloud declining to reply, not a connection failure — those are reported separately.${verdict}`
|
|
3426
3489
|
);
|
|
3427
3490
|
return true;
|
|
3428
3491
|
}
|
|
@@ -6110,6 +6173,8 @@ class Roborock {
|
|
|
6110
6173
|
) {
|
|
6111
6174
|
return liveState.current;
|
|
6112
6175
|
}
|
|
6176
|
+
this.unansweredMethods.govern(duid, "get_map_list");
|
|
6177
|
+
this.unansweredMethods.govern(duid, b01Q7Adapter.B01_MAP_UPLOAD_METHOD);
|
|
6113
6178
|
liveState.lastAttemptAt = Date.now();
|
|
6114
6179
|
|
|
6115
6180
|
liveState.inflight = (async () => {
|
|
@@ -6436,6 +6501,7 @@ class Roborock {
|
|
|
6436
6501
|
if (this.unansweredMethods.shouldSkip(duid, "get_map_v1")) {
|
|
6437
6502
|
return liveState.current;
|
|
6438
6503
|
}
|
|
6504
|
+
this.unansweredMethods.govern(duid, "get_map_v1");
|
|
6439
6505
|
liveState.lastAttemptAt = Date.now();
|
|
6440
6506
|
|
|
6441
6507
|
liveState.inflight = (async () => {
|