homebridge-roborock-matter 3.21.2 → 3.21.3

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.21.3
4
+
5
+ **A robot that refuses a request was reported as a robot that answered nothing.**
6
+
7
+ DSimeone1989 reported in #22 that his Roborock app schedules never appear. His Saros 10R (`roborock.vacuum.a144`) answers `get_status`, `get_timer`, `get_carpet_mode` and `get_water_box_custom_mode` over the cloud in the same second, and refuses `get_server_timer`. All the plugin could say about it was:
8
+
9
+ ```
10
+ Cloud message with protocol 102 and id 10 received. Result: undefined
11
+ Schedule discovery for 1MDui…: type=undefined, value=undefined
12
+ Unable to reliably read Roborock schedules …: get_server_timer returned undefined
13
+ ```
14
+
15
+ **The reason was decoded and then dropped.** A Roborock reply carries its payload in `result`. Both connectors handed `result` straight to the waiting promise without asking whether the reply had one, so a refusal resolved as a success whose value happened to be `undefined` — the same value a caller sees for a reply the parser could not read, and indistinguishable from a genuine empty answer. Whatever the robot said about why now never reached a log line, an error, or the user.
16
+
17
+ **A reply with no `result` is no longer treated as an empty answer.** When the robot spells out a refusal, the waiting caller gets it as an error naming the method and the robot's own words, over both the cloud and the LAN socket. When there is no result and no stated reason, the debug log prints the reply itself instead of the word `undefined`.
18
+
19
+ Deliberately narrow: a reply that carries a `result` is untouched, and an empty array stays an authoritative "you have no timers" rather than becoming an error. Only a refusal the robot actually stated changes behaviour.
20
+
21
+ 11 new tests, red against the old code on both halves.
22
+
3
23
  ## 3.21.2
4
24
 
5
25
  **Driving through a room still marked it cleaned. 3.19.7 was meant to fix that and did not.**
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. 1695 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. 1707 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
 
@@ -257,7 +257,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
257
257
 
258
258
  ## Contributing
259
259
 
260
- Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1695 tests (protocol fixtures verified against the [python-roborock](https://github.com/Python-roborock/python-roborock) reference), strict TypeScript checking, and CI across Node 22/24 × Homebridge 1.11/2.x — `npm test` before you push and you're set.
260
+ Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1707 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.
261
261
 
262
262
  ## Support the project
263
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.21.2",
3
+ "version": "3.21.3",
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": {
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * A Roborock reply carries its payload in `result`. A reply that has an `id`
5
+ * but no `result` at all is not an empty success — the robot took the request
6
+ * and declined it, and when it says why, it says so in `error`.
7
+ *
8
+ * Both connectors used to hand `result` straight to the waiting promise, so
9
+ * such a reply resolved with `undefined` and the refusal was dropped one line
10
+ * before anyone could read it. Issue #22 is what that costs: a Saros 10R
11
+ * (`roborock.vacuum.a144`) answers every other method and refuses
12
+ * `get_server_timer`, and the only thing the plugin could tell its owner was
13
+ * `get_server_timer returned undefined` — the same sentence it would print for
14
+ * a timeout, a parser gap or a firmware that has no such method.
15
+ *
16
+ * Kept deliberately narrow: a reply that carries a `result` is never touched,
17
+ * and a resultless reply with no error field still resolves as before. Only a
18
+ * refusal the robot spelled out is turned into a rejection.
19
+ *
20
+ * @param {unknown} reply decoded reply body (protocol 102 over cloud, 4 over LAN)
21
+ * @returns {string|null} the robot's own words, or null when there is no refusal to report
22
+ */
23
+ function describeReplyRefusal(reply) {
24
+ if (!reply || typeof reply !== "object") {
25
+ return null;
26
+ }
27
+
28
+ if (typeof reply.result !== "undefined") {
29
+ return null;
30
+ }
31
+
32
+ const error = reply.error;
33
+
34
+ if (error === undefined || error === null || error === "") {
35
+ return null;
36
+ }
37
+
38
+ if (typeof error !== "object") {
39
+ return String(error);
40
+ }
41
+
42
+ const code = error.code;
43
+ const message = error.message;
44
+ const hasCode = code !== undefined && code !== null;
45
+ const hasMessage = typeof message === "string" && message.length > 0;
46
+
47
+ if (hasCode && hasMessage) {
48
+ return `${message} (code ${code})`;
49
+ }
50
+ if (hasMessage) {
51
+ return message;
52
+ }
53
+ if (hasCode) {
54
+ return `code ${code}`;
55
+ }
56
+
57
+ // An error object in a shape nobody here has seen yet is still worth more
58
+ // to the reader than `undefined`.
59
+ try {
60
+ return JSON.stringify(error);
61
+ } catch {
62
+ return String(error);
63
+ }
64
+ }
65
+
66
+ module.exports = { describeReplyRefusal };
@@ -5,6 +5,7 @@ const Parser = require("binary-parser").Parser;
5
5
  const net = require("net");
6
6
  const dgram = require("dgram");
7
7
  const { describeDevice } = require("./describeDevice");
8
+ const { describeReplyRefusal } = require("./describeReplyRefusal");
8
9
 
9
10
  const PORT = 58866;
10
11
  const TIMEOUT = 5000; // 5 Sekunden Timeout
@@ -584,17 +585,30 @@ class localConnector {
584
585
  const result = parsed_102.result;
585
586
 
586
587
  if (this.adapter.pendingRequests.has(id)) {
588
+ const refusal = describeReplyRefusal(parsed_102);
587
589
  this.adapter.log.debug(
588
- `Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`
590
+ typeof result === "undefined"
591
+ ? `Local message with protocol 4 and id ${id} received. No result; reply was ${JSON.stringify(parsed_102)}`
592
+ : `Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`
589
593
  );
590
- const { resolve, timeout } = this.adapter.pendingRequests.get(id);
594
+ const { resolve, reject, timeout, method } =
595
+ this.adapter.pendingRequests.get(id);
591
596
  this.adapter.clearTimeout(timeout);
592
597
  this.adapter.pendingRequests.delete(id);
593
598
  // Proof that this socket is not mute, so any run of timeouts counted
594
- // against it starts over.
599
+ // against it starts over. A refusal still proves the socket answers —
600
+ // it is the request that failed, not the transport.
595
601
  if (this.adapter.noteLocalRequestSucceeded) {
596
602
  this.adapter.noteLocalRequestSucceeded(duid);
597
603
  }
604
+ if (refusal && typeof reject === "function") {
605
+ reject(
606
+ new Error(
607
+ `The robot refused ${method || "the request"} (local id ${id}): ${refusal}`
608
+ )
609
+ );
610
+ return;
611
+ }
598
612
  resolve(result);
599
613
 
600
614
  if (this.adapter.deviceNotify !== undefined) {
@@ -6,6 +6,7 @@ const Parser = require("binary-parser").Parser;
6
6
  const zlib = require("zlib");
7
7
  const roborockCrypto = require("./roborockCrypto");
8
8
  const { describeDevice } = require("./describeDevice");
9
+ const { describeReplyRefusal } = require("./describeReplyRefusal");
9
10
 
10
11
  const PHOTO_MAGIC = "ROBOROCK";
11
12
  const PHOTO_HEADER_MIN_LENGTH = 9;
@@ -364,8 +365,15 @@ class roborock_mqtt_connector {
364
365
  // Runs for every cloud message; only pay the stringify cost
365
366
  // when debug logging is actually enabled.
366
367
  if (this.adapter.config.debug) {
368
+ // A reply with no `result` used to print "Result: undefined",
369
+ // which reads like a robot that said nothing. It said something;
370
+ // it just did not say it in `result`. Print the reply itself so
371
+ // the refusal is on the record even when nobody is waiting for
372
+ // this id any more.
367
373
  this.adapter.log.debug(
368
- `Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
374
+ typeof dps.result === "undefined"
375
+ ? `Cloud message with protocol 102 and id ${dps.id} received. No result; reply was ${JSON.stringify(dps)}`
376
+ : `Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
369
377
  );
370
378
  }
371
379
  if (typeof dps.result !== "undefined") {
@@ -403,7 +411,19 @@ class roborock_mqtt_connector {
403
411
  if (shouldResolveOn102(pending, dps.result)) {
404
412
  this.adapter.clearTimeout(pending.timeout);
405
413
  this.adapter.pendingRequests.delete(dps.id);
406
- pending.resolve(dps.result);
414
+ // A refusal is a failed request, not an empty one. Resolving it
415
+ // with `undefined` is indistinguishable from a real empty answer
416
+ // to every caller upstream — see describeReplyRefusal.
417
+ const refusal = describeReplyRefusal(dps);
418
+ if (refusal) {
419
+ pending.reject(
420
+ new Error(
421
+ `The robot refused ${pending.method || "the request"} (cloud id ${dps.id}): ${refusal}`
422
+ )
423
+ );
424
+ } else {
425
+ pending.resolve(dps.result);
426
+ }
407
427
  }
408
428
  // protocol 300 seems to be for get_photo 0 only. get_photo 0 is for large images. 1 is for small images.
409
429
  } else if (data.protocol == 300) {