homebridge-roborock-matter 3.21.2 → 3.21.4
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,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.21.4
|
|
4
|
+
|
|
5
|
+
**The refusal 3.21.3 made visible was then reported as a plugin crash, twice per poll cycle, forever.**
|
|
6
|
+
|
|
7
|
+
DSimeone1989 ran 3.21.3 and sent the line it was written to produce. His Saros 10R answers:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Cloud message with protocol 102 and id 5 received. No result; reply was {"id":5,"error":{"code":-10007,"message":"Not FCC robot"}}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That is the answer: the robot's firmware declines `get_server_timer` outright. The fix worked. What it also produced was this, every poll cycle, for a robot behaving exactly as intended:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Failed to execute get_server_timer on robot Rocky (roborock.vacuum.a144): Error: The robot refused get_server_timer (cloud id 5): Not FCC robot (code -10007)
|
|
17
|
+
at MqttClient.<anonymous> (…/roborock_mqtt_connector.js:420:17)
|
|
18
|
+
at MqttClient.emit (node:events:514:28)
|
|
19
|
+
… eight more frames
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**A stated refusal was thrown as a bare `Error`.** It carried no code, so it matched none of `catchError`'s calm branches and fell through to the final `else`, which logs `error.stack`. The stack names our own MQTT handler and describes nothing that went wrong. Because the schedule coordinator and the generic poll both ask, it was emitted twice per cycle, indefinitely.
|
|
23
|
+
|
|
24
|
+
**A refusal the robot spelled out is now a capability fact, not a failure.** It is tagged where it is constructed, carries the robot's own error code, is reported once per robot per method so the owner learns why a feature is missing, and then drops to debug. It never carries a stack trace and never escalates to `log.error`.
|
|
25
|
+
|
|
26
|
+
Deliberately narrow: transport failures are untouched. A robot that is unreachable, a dead cloud link and a missing local socket all keep their existing loud paths — quieting those would tell an owner nothing is wrong while their robot is offline.
|
|
27
|
+
|
|
28
|
+
## 3.21.3
|
|
29
|
+
|
|
30
|
+
**A robot that refuses a request was reported as a robot that answered nothing.**
|
|
31
|
+
|
|
32
|
+
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:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Cloud message with protocol 102 and id 10 received. Result: undefined
|
|
36
|
+
Schedule discovery for 1MDui…: type=undefined, value=undefined
|
|
37
|
+
Unable to reliably read Roborock schedules …: get_server_timer returned undefined
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**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.
|
|
41
|
+
|
|
42
|
+
**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`.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
11 new tests, red against the old code on both halves.
|
|
47
|
+
|
|
3
48
|
## 3.21.2
|
|
4
49
|
|
|
5
50
|
**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.
|
|
40
|
+
- 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 1720 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
|
|
260
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1720 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.
|
|
3
|
+
"version": "3.21.4",
|
|
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,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marks an error as "the robot answered and declined", as opposed to anything
|
|
5
|
+
* that went wrong on our side or on the wire. `catchError` keys its calm
|
|
6
|
+
* branch on this.
|
|
7
|
+
*/
|
|
8
|
+
const METHOD_REFUSED_CODE = "ROBOROCK_METHOD_REFUSED";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Roborock reply carries its payload in `result`. A reply that has an `id`
|
|
12
|
+
* but no `result` at all is not an empty success — the robot took the request
|
|
13
|
+
* and declined it, and when it says why, it says so in `error`.
|
|
14
|
+
*
|
|
15
|
+
* Both connectors used to hand `result` straight to the waiting promise, so
|
|
16
|
+
* such a reply resolved with `undefined` and the refusal was dropped one line
|
|
17
|
+
* before anyone could read it. Issue #22 is what that costs: a Saros 10R
|
|
18
|
+
* (`roborock.vacuum.a144`) answers every other method and refuses
|
|
19
|
+
* `get_server_timer`, and the only thing the plugin could tell its owner was
|
|
20
|
+
* `get_server_timer returned undefined` — the same sentence it would print for
|
|
21
|
+
* a timeout, a parser gap or a firmware that has no such method.
|
|
22
|
+
*
|
|
23
|
+
* Kept deliberately narrow: a reply that carries a `result` is never touched,
|
|
24
|
+
* and a resultless reply with no error field still resolves as before. Only a
|
|
25
|
+
* refusal the robot spelled out is turned into a rejection.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown} reply decoded reply body (protocol 102 over cloud, 4 over LAN)
|
|
28
|
+
* @returns {string|null} the robot's own words, or null when there is no refusal to report
|
|
29
|
+
*/
|
|
30
|
+
function describeReplyRefusal(reply) {
|
|
31
|
+
if (!reply || typeof reply !== "object") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof reply.result !== "undefined") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const error = reply.error;
|
|
40
|
+
|
|
41
|
+
if (error === undefined || error === null || error === "") {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof error !== "object") {
|
|
46
|
+
return String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const code = error.code;
|
|
50
|
+
const message = error.message;
|
|
51
|
+
const hasCode = code !== undefined && code !== null;
|
|
52
|
+
const hasMessage = typeof message === "string" && message.length > 0;
|
|
53
|
+
|
|
54
|
+
if (hasCode && hasMessage) {
|
|
55
|
+
return `${message} (code ${code})`;
|
|
56
|
+
}
|
|
57
|
+
if (hasMessage) {
|
|
58
|
+
return message;
|
|
59
|
+
}
|
|
60
|
+
if (hasCode) {
|
|
61
|
+
return `code ${code}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// An error object in a shape nobody here has seen yet is still worth more
|
|
65
|
+
// to the reader than `undefined`.
|
|
66
|
+
try {
|
|
67
|
+
return JSON.stringify(error);
|
|
68
|
+
} catch {
|
|
69
|
+
return String(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A robot that answers a request and declines it has told us a fact about
|
|
75
|
+
* itself. It is not a plugin failure, and it must not be rendered as one.
|
|
76
|
+
*
|
|
77
|
+
* 3.21.3 made the refusal visible but threw it as a bare `Error`, so it
|
|
78
|
+
* carried no code and matched none of `catchError`'s calm branches. It landed
|
|
79
|
+
* on the final `else` and was logged with `error.stack` — an ERROR line plus a
|
|
80
|
+
* ten-frame JavaScript stack trace pointing at our own MQTT handler, twice per
|
|
81
|
+
* poll cycle, for as long as the robot is on the account. Issue #22's Saros 10R
|
|
82
|
+
* refuses `get_server_timer` with `Not FCC robot (code -10007)` on every poll:
|
|
83
|
+
* the stack trace describes nothing that went wrong, and the repetition buries
|
|
84
|
+
* real errors.
|
|
85
|
+
*
|
|
86
|
+
* Tagging the error at the point of construction is what puts it on a calm
|
|
87
|
+
* branch by construction, rather than leaving every caller to recognise a
|
|
88
|
+
* refusal from its prose — the failure mode `getTransientErrorKind` already
|
|
89
|
+
* documents.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} message human-readable refusal, already naming method and id
|
|
92
|
+
* @param {unknown} reply the decoded reply the refusal came from
|
|
93
|
+
* @returns {Error & { code: string, robotErrorCode?: unknown }}
|
|
94
|
+
*/
|
|
95
|
+
function createRefusalError(message, reply) {
|
|
96
|
+
const error =
|
|
97
|
+
/** @type {Error & { code: string, robotErrorCode?: unknown }} */ (
|
|
98
|
+
new Error(message)
|
|
99
|
+
);
|
|
100
|
+
error.code = METHOD_REFUSED_CODE;
|
|
101
|
+
|
|
102
|
+
const replyError =
|
|
103
|
+
reply && typeof reply === "object"
|
|
104
|
+
? /** @type {any} */ (reply).error
|
|
105
|
+
: null;
|
|
106
|
+
if (replyError && typeof replyError === "object") {
|
|
107
|
+
const code = /** @type {any} */ (replyError).code;
|
|
108
|
+
if (code !== undefined && code !== null) {
|
|
109
|
+
error.robotErrorCode = code;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
describeReplyRefusal,
|
|
118
|
+
createRefusalError,
|
|
119
|
+
METHOD_REFUSED_CODE,
|
|
120
|
+
};
|
|
@@ -5,6 +5,10 @@ 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 {
|
|
9
|
+
describeReplyRefusal,
|
|
10
|
+
createRefusalError,
|
|
11
|
+
} = require("./describeReplyRefusal");
|
|
8
12
|
|
|
9
13
|
const PORT = 58866;
|
|
10
14
|
const TIMEOUT = 5000; // 5 Sekunden Timeout
|
|
@@ -584,17 +588,31 @@ class localConnector {
|
|
|
584
588
|
const result = parsed_102.result;
|
|
585
589
|
|
|
586
590
|
if (this.adapter.pendingRequests.has(id)) {
|
|
591
|
+
const refusal = describeReplyRefusal(parsed_102);
|
|
587
592
|
this.adapter.log.debug(
|
|
588
|
-
|
|
593
|
+
typeof result === "undefined"
|
|
594
|
+
? `Local message with protocol 4 and id ${id} received. No result; reply was ${JSON.stringify(parsed_102)}`
|
|
595
|
+
: `Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`
|
|
589
596
|
);
|
|
590
|
-
const { resolve, timeout } =
|
|
597
|
+
const { resolve, reject, timeout, method } =
|
|
598
|
+
this.adapter.pendingRequests.get(id);
|
|
591
599
|
this.adapter.clearTimeout(timeout);
|
|
592
600
|
this.adapter.pendingRequests.delete(id);
|
|
593
601
|
// Proof that this socket is not mute, so any run of timeouts counted
|
|
594
|
-
// against it starts over.
|
|
602
|
+
// against it starts over. A refusal still proves the socket answers —
|
|
603
|
+
// it is the request that failed, not the transport.
|
|
595
604
|
if (this.adapter.noteLocalRequestSucceeded) {
|
|
596
605
|
this.adapter.noteLocalRequestSucceeded(duid);
|
|
597
606
|
}
|
|
607
|
+
if (refusal && typeof reject === "function") {
|
|
608
|
+
reject(
|
|
609
|
+
createRefusalError(
|
|
610
|
+
`The robot refused ${method || "the request"} (local id ${id}): ${refusal}`,
|
|
611
|
+
parsed_102
|
|
612
|
+
)
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
598
616
|
resolve(result);
|
|
599
617
|
|
|
600
618
|
if (this.adapter.deviceNotify !== undefined) {
|
|
@@ -6,6 +6,10 @@ 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 {
|
|
10
|
+
describeReplyRefusal,
|
|
11
|
+
createRefusalError,
|
|
12
|
+
} = require("./describeReplyRefusal");
|
|
9
13
|
|
|
10
14
|
const PHOTO_MAGIC = "ROBOROCK";
|
|
11
15
|
const PHOTO_HEADER_MIN_LENGTH = 9;
|
|
@@ -364,8 +368,15 @@ class roborock_mqtt_connector {
|
|
|
364
368
|
// Runs for every cloud message; only pay the stringify cost
|
|
365
369
|
// when debug logging is actually enabled.
|
|
366
370
|
if (this.adapter.config.debug) {
|
|
371
|
+
// A reply with no `result` used to print "Result: undefined",
|
|
372
|
+
// which reads like a robot that said nothing. It said something;
|
|
373
|
+
// it just did not say it in `result`. Print the reply itself so
|
|
374
|
+
// the refusal is on the record even when nobody is waiting for
|
|
375
|
+
// this id any more.
|
|
367
376
|
this.adapter.log.debug(
|
|
368
|
-
|
|
377
|
+
typeof dps.result === "undefined"
|
|
378
|
+
? `Cloud message with protocol 102 and id ${dps.id} received. No result; reply was ${JSON.stringify(dps)}`
|
|
379
|
+
: `Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
|
|
369
380
|
);
|
|
370
381
|
}
|
|
371
382
|
if (typeof dps.result !== "undefined") {
|
|
@@ -403,7 +414,20 @@ class roborock_mqtt_connector {
|
|
|
403
414
|
if (shouldResolveOn102(pending, dps.result)) {
|
|
404
415
|
this.adapter.clearTimeout(pending.timeout);
|
|
405
416
|
this.adapter.pendingRequests.delete(dps.id);
|
|
406
|
-
|
|
417
|
+
// A refusal is a failed request, not an empty one. Resolving it
|
|
418
|
+
// with `undefined` is indistinguishable from a real empty answer
|
|
419
|
+
// to every caller upstream — see describeReplyRefusal.
|
|
420
|
+
const refusal = describeReplyRefusal(dps);
|
|
421
|
+
if (refusal) {
|
|
422
|
+
pending.reject(
|
|
423
|
+
createRefusalError(
|
|
424
|
+
`The robot refused ${pending.method || "the request"} (cloud id ${dps.id}): ${refusal}`,
|
|
425
|
+
dps
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
} else {
|
|
429
|
+
pending.resolve(dps.result);
|
|
430
|
+
}
|
|
407
431
|
}
|
|
408
432
|
// protocol 300 seems to be for get_photo 0 only. get_photo 0 is for large images. 1 is for small images.
|
|
409
433
|
} else if (data.protocol == 300) {
|
|
@@ -20,6 +20,7 @@ const RRMapParser = require("./lib/RRMapParser");
|
|
|
20
20
|
const messageQueueHandler =
|
|
21
21
|
require("./lib/messageQueueHandler").messageQueueHandler;
|
|
22
22
|
const roborockCrypto = require("./lib/roborockCrypto");
|
|
23
|
+
const { METHOD_REFUSED_CODE } = require("./lib/describeReplyRefusal");
|
|
23
24
|
const b01Q7Adapter = require("./lib/b01Q7Adapter");
|
|
24
25
|
|
|
25
26
|
// v1 states in which the robot is actively doing something and state
|
|
@@ -4316,6 +4317,33 @@ class Roborock {
|
|
|
4316
4317
|
return;
|
|
4317
4318
|
}
|
|
4318
4319
|
|
|
4320
|
+
// A refusal the robot spelled out is a fact about that robot, not a
|
|
4321
|
+
// plugin failure: nothing on our side went wrong, so there is no stack
|
|
4322
|
+
// worth printing, and the same robot will keep saying the same thing on
|
|
4323
|
+
// every poll. Say it once so the owner learns why a feature is missing,
|
|
4324
|
+
// then keep quiet. (Issue #22: a Saros 10R refuses `get_server_timer`
|
|
4325
|
+
// with "Not FCC robot", which 3.21.3 rendered as an ERROR plus a
|
|
4326
|
+
// ten-frame stack trace twice per poll cycle, indefinitely.)
|
|
4327
|
+
if (
|
|
4328
|
+
error &&
|
|
4329
|
+
typeof error === "object" &&
|
|
4330
|
+
error.code === METHOD_REFUSED_CODE
|
|
4331
|
+
) {
|
|
4332
|
+
if (!this._reportedMethodRefusals) {
|
|
4333
|
+
this._reportedMethodRefusals = new Set();
|
|
4334
|
+
}
|
|
4335
|
+
const seenKey = `${duid || "unknown"}:${attribute || "unknown"}`;
|
|
4336
|
+
if (this._reportedMethodRefusals.has(seenKey)) {
|
|
4337
|
+
this.log.debug(errorText);
|
|
4338
|
+
} else {
|
|
4339
|
+
this._reportedMethodRefusals.add(seenKey);
|
|
4340
|
+
this.log.warn(
|
|
4341
|
+
`${this.describeDevice(duid)} (${model || "unknown model"}) refuses ${attribute}: ${error.message}. This is the robot's own answer, not a plugin failure; it will not be reported again this session.`
|
|
4342
|
+
);
|
|
4343
|
+
}
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4319
4347
|
const transientErrorKind =
|
|
4320
4348
|
(typeof error === "object" && error?.transientKind) ||
|
|
4321
4349
|
this.getTransientErrorKind(errorText);
|