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. 2031 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. 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 2031 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.
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.31.0",
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
- reject(
545
- new Error(
546
- `Cloud request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds. MQTT connection state: ${mqttConnectionState}${describeCloudSilence(this.adapter, duid, receiptsAtSend)}`
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
- reject(
560
- new Error(
561
- `Local request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds Local connect state: ${localConnectionState}`
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
- resolve,
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
- try {
3369
- const answer = await vacuum.getParameter(duid, method);
3370
- this.noteMethodAnswered(duid, method);
3371
- return answer;
3372
- } catch (error) {
3373
- this.noteMethodUnanswered(duid, method, error);
3374
- throw error;
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 () => {