homebridge-roborock-matter 3.25.0 → 3.26.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 +37 -0
- package/README.md +2 -2
- package/config.schema.json +1 -1
- package/package.json +1 -1
- package/roborockLib/roborockAPI.js +137 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.26.0
|
|
4
|
+
|
|
5
|
+
**The refused route answered, and what it said was that the route exists — just not for the verb we asked with. So this release asks it which verb it does take, without attempting one.**
|
|
6
|
+
|
|
7
|
+
3.25.1 taught the schedule probe to keep the body of a refusal. Issue #22's reporter ran it, and the body was worth the release:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
GET user/scene/<id> → 400
|
|
11
|
+
{"code":"servlet.exception","msg":"Request method 'GET' is not supported", ...}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That is not "no such route". A servlet says that sentence when the path _is_ mapped and the method is not — so the singular scene resource exists, and something other than GET reaches it.
|
|
15
|
+
|
|
16
|
+
Two releases have now established _what_ a HomeKit switch over these schedules would have to change: the `enabled` flag inside a scene's TIMER trigger, not the scene's own. Neither established _where_ to send it, and this thread has twice refused to guess, because a guessed write endpoint against somebody's live account does not fail politely.
|
|
17
|
+
|
|
18
|
+
There is a way to ask a resource which methods it accepts without attempting any of them, and it is what OPTIONS is for. It is a safe method, it carries no body, and it cannot alter a schedule. When the probe finds a timer-driven scene, it now asks that one scene resource the question and records the `Allow` header that answers it.
|
|
19
|
+
|
|
20
|
+
The probe was also dropping that header — on refusals and successes alike — which is the same defect 3.25.1 fixed one field over: a refusal saying "GET is not supported" names the verb that failed and not the ones that would work, and `Allow` is where a servlet puts those. It is now kept wherever it appears.
|
|
21
|
+
|
|
22
|
+
Exactly one header is taken, deliberately. A response header block carries session material, and a diagnostic somebody pastes into a public issue must not leak their cookie to measure a verb.
|
|
23
|
+
|
|
24
|
+
The constraints are otherwise unchanged and one of them is now stated more precisely: safe methods only — GET and OPTIONS — debug logging only, once per robot per session, cannot throw, credential-shaped fields redacted. Still a diagnostic, still not a feature.
|
|
25
|
+
|
|
26
|
+
## 3.25.1
|
|
27
|
+
|
|
28
|
+
**The schedule probe kept the status code of a refused route and threw away the server's explanation of it — so the route it was built to measure came back saying nothing.**
|
|
29
|
+
|
|
30
|
+
3.25.0 shipped a read of the singular cloud scene resource, on the reasoning that a 200 makes it a candidate for a later write and a 404 rules it out for free. Issue #22's reporter ran it, and the answer was neither: `400`.
|
|
31
|
+
|
|
32
|
+
That is the one outcome the probe had nothing to say about. A 404 means there is no such resource. A 400 means the server routed the request and then rejected it — which is a statement about the request, not about whether the route exists. Whether it means "unknown route", "wrong method" or "that scene is not yours" lives in the body the server sent back, and axios flattens every one of those into the same sentence: `Request failed with status code 400`.
|
|
33
|
+
|
|
34
|
+
The probe recorded that sentence and dropped the body. So a measurement that took a release to ship, and somebody else's live account to run, produced a number and no reading.
|
|
35
|
+
|
|
36
|
+
A refused route now keeps what the server actually said, in the log and in the diagnostics record, under the same compaction and redaction a successful answer already gets — an error envelope is no more ours to print blindly than a normal one. This holds for every route the probe reads, not just the one it was found on.
|
|
37
|
+
|
|
38
|
+
No behaviour outside the diagnostic changes, and the probe is still debug-only, GET-only, once per robot per session, and unable to throw.
|
|
39
|
+
|
|
3
40
|
## 3.25.0
|
|
4
41
|
|
|
5
42
|
**The schedule probe now measures whether a cloud schedule is a resource the plugin could ever write to.**
|
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. 1854 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
|
|
|
@@ -258,7 +258,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
258
258
|
|
|
259
259
|
## Contributing
|
|
260
260
|
|
|
261
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
261
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1854 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.
|
|
262
262
|
|
|
263
263
|
## Support the project
|
|
264
264
|
|
package/config.schema.json
CHANGED
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
},
|
|
77
77
|
"enableLiveRoomTracking": {
|
|
78
78
|
"title": "Enable Live Room Tracking",
|
|
79
|
-
"description": "While a
|
|
79
|
+
"description": "While a robot is actively cleaning, periodically fetch the robot's map position (every ~10 seconds) and report the room it is physically inside as the current Matter Service Area, so controllers like Apple Home can show 'cleaning in <room>'. Both generations are covered: B01/Q7 robots via the SCMap position, classic S/Q-series robots via the RRMap segment grid. Requires Matter Room/Map Selection. Disable to avoid the extra map traffic.",
|
|
80
80
|
"type": "boolean",
|
|
81
81
|
"default": true
|
|
82
82
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.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": {
|
|
@@ -4560,9 +4560,11 @@ class Roborock {
|
|
|
4560
4560
|
* instead of a guess. Deliberately constrained:
|
|
4561
4561
|
*
|
|
4562
4562
|
* - **Debug only.** Silent for every installation that has not asked for it.
|
|
4563
|
-
* - **GET
|
|
4564
|
-
* signs an empty body (`roborockAPI.js`
|
|
4565
|
-
* body-bearing write would not authenticate
|
|
4563
|
+
* - **Safe methods only — GET and OPTIONS.** Nothing here can change a
|
|
4564
|
+
* schedule. The Hawk interceptor signs an empty body (`roborockAPI.js`
|
|
4565
|
+
* request interceptor), so a body-bearing write would not authenticate
|
|
4566
|
+
* anyway. OPTIONS carries no body either: it asks a resource which methods
|
|
4567
|
+
* it accepts, which is how you find a write target without attempting one.
|
|
4566
4568
|
* - **Once per robot per session,** so a poll cadence cannot turn it into
|
|
4567
4569
|
* traffic.
|
|
4568
4570
|
* - **Never throws.** A probe that breaks startup would be worse than the
|
|
@@ -4592,23 +4594,78 @@ class Roborock {
|
|
|
4592
4594
|
}
|
|
4593
4595
|
|
|
4594
4596
|
/**
|
|
4595
|
-
*
|
|
4597
|
+
* Pluck the one response header that says which methods a resource takes.
|
|
4596
4598
|
*
|
|
4597
|
-
*
|
|
4598
|
-
*
|
|
4599
|
-
*
|
|
4600
|
-
*
|
|
4601
|
-
*
|
|
4599
|
+
* The reporter's 3.25.1 reading is why this exists: `GET user/scene/{id}`
|
|
4600
|
+
* came back `400` carrying `"Request method 'GET' is not supported"`. That
|
|
4601
|
+
* sentence is a servlet container's way of saying the path IS mapped, just
|
|
4602
|
+
* not for the verb we asked with — so the resource exists and something
|
|
4603
|
+
* else reaches it. Which verb that is belongs in the `Allow` header, and a
|
|
4604
|
+
* probe that keeps the body but drops the headers is the same defect as
|
|
4605
|
+
* 3.25.1's, one field over.
|
|
4606
|
+
*
|
|
4607
|
+
* Deliberately ONE header rather than the block. Response headers carry
|
|
4608
|
+
* `set-cookie` and session material; a diagnostic that printed the lot
|
|
4609
|
+
* would leak more than it measures. `Allow` is the answer and the only
|
|
4610
|
+
* thing taken.
|
|
4611
|
+
*
|
|
4612
|
+
* @param {unknown} headers axios response headers — an `AxiosHeaders`
|
|
4613
|
+
* instance in production, a plain object in tests
|
|
4614
|
+
* @returns {string | undefined} the header value, or nothing when absent
|
|
4615
|
+
*/
|
|
4616
|
+
readAllowedMethods(headers) {
|
|
4617
|
+
if (!headers || typeof headers !== "object") {
|
|
4618
|
+
return undefined;
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
/** @type {unknown} */
|
|
4622
|
+
let value;
|
|
4623
|
+
const accessor = /** @type {{get?: unknown}} */ (headers).get;
|
|
4624
|
+
if (typeof accessor === "function") {
|
|
4625
|
+
try {
|
|
4626
|
+
value = accessor.call(headers, "allow");
|
|
4627
|
+
} catch {
|
|
4628
|
+
value = undefined;
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
if (value === undefined || value === null) {
|
|
4633
|
+
const match = Object.entries(headers).find(
|
|
4634
|
+
([key]) => key.toLowerCase() === "allow"
|
|
4635
|
+
);
|
|
4636
|
+
value = match?.[1];
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
if (value === undefined || value === null || value === "") {
|
|
4640
|
+
return undefined;
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
return Array.isArray(value) ? value.join(", ") : String(value);
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
/**
|
|
4647
|
+
* Read one candidate cloud schedule route, record it, and log what came back.
|
|
4648
|
+
*
|
|
4649
|
+
* Extracted so that every route — the two candidates, the singular scene
|
|
4650
|
+
* resource probed after them, and the OPTIONS question asked of that same
|
|
4651
|
+
* resource — shares one error path. It never throws: this rides along on a
|
|
4652
|
+
* live poll, and a route that does not exist is a measurement rather than a
|
|
4653
|
+
* fault, so a failure is recorded and logged at debug level instead of
|
|
4654
|
+
* surfacing.
|
|
4602
4655
|
*
|
|
4603
4656
|
* @param {string} duid robot the reading belongs to
|
|
4604
|
-
* @param {{label: string, path: string}} route label to
|
|
4605
|
-
* the path to read
|
|
4657
|
+
* @param {{label: string, path: string, method?: string}} route label to
|
|
4658
|
+
* file it under, the path to read, and the safe method to read it with
|
|
4659
|
+
* (`get` unless stated)
|
|
4606
4660
|
* @param {Record<string, unknown>} results accumulator, written in place
|
|
4607
4661
|
* @returns {Promise<void>}
|
|
4608
4662
|
*/
|
|
4609
4663
|
async probeOneCloudScheduleRoute(duid, route, results) {
|
|
4664
|
+
const method = route.method === "options" ? "options" : "get";
|
|
4665
|
+
const verb = method.toUpperCase();
|
|
4666
|
+
|
|
4610
4667
|
try {
|
|
4611
|
-
const response = await this.api
|
|
4668
|
+
const response = await this.api[method](route.path);
|
|
4612
4669
|
// Roborock wraps most answers in `{api,result,status,success}`. Keep
|
|
4613
4670
|
// the envelope only when there is no `result` to unwrap, so the log
|
|
4614
4671
|
// shows the payload rather than the wrapper.
|
|
@@ -4624,17 +4681,28 @@ class Roborock {
|
|
|
4624
4681
|
// is intact.
|
|
4625
4682
|
const decoded = this.describeCloudScheduleAnswer(payload);
|
|
4626
4683
|
|
|
4684
|
+
// An OPTIONS answer says nothing in its body — the whole reading is the
|
|
4685
|
+
// `Allow` header — so keep it on the success path too, not only on a
|
|
4686
|
+
// refusal.
|
|
4687
|
+
const allow = this.readAllowedMethods(response?.headers);
|
|
4688
|
+
|
|
4627
4689
|
results[route.label] = {
|
|
4628
4690
|
path: route.path,
|
|
4691
|
+
method: verb,
|
|
4629
4692
|
ok: true,
|
|
4630
4693
|
response: payload,
|
|
4694
|
+
allow,
|
|
4631
4695
|
schedules: decoded.length > 0 ? decoded : undefined,
|
|
4632
4696
|
};
|
|
4633
4697
|
|
|
4634
4698
|
this.log.debug(
|
|
4635
|
-
`Roborock cloud schedule probe for ${this.describeDevice(duid)} —
|
|
4699
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${verb} ${route.path} answered: ${JSON.stringify(
|
|
4636
4700
|
this.compactDiagnosticPayload(payload)
|
|
4637
|
-
)}
|
|
4701
|
+
)}${
|
|
4702
|
+
allow === undefined
|
|
4703
|
+
? ""
|
|
4704
|
+
: ` — the resource allows: ${this.compactDiagnosticPayload(allow)}`
|
|
4705
|
+
}`
|
|
4638
4706
|
);
|
|
4639
4707
|
|
|
4640
4708
|
if (decoded.length > 0) {
|
|
@@ -4653,17 +4721,48 @@ class Roborock {
|
|
|
4653
4721
|
const message =
|
|
4654
4722
|
error instanceof Error ? error.message : String(error ?? "");
|
|
4655
4723
|
|
|
4724
|
+
// The status alone does not measure the route, and the reporter's answer
|
|
4725
|
+
// is why: `user/scene/{id}` came back `400`, not `404`. A 404 would have
|
|
4726
|
+
// ruled the resource out; a 400 says the server routed the request and
|
|
4727
|
+
// then rejected it, and only its own body says whether that is "no such
|
|
4728
|
+
// route", "wrong method" or "that scene is not yours". Axios flattens all
|
|
4729
|
+
// of it into `Request failed with status code 400`, which carries nothing.
|
|
4730
|
+
//
|
|
4731
|
+
// So keep the body. It goes through the same compaction and redaction as
|
|
4732
|
+
// a successful answer, because an error envelope is no more ours to print
|
|
4733
|
+
// blindly than a successful one.
|
|
4734
|
+
const body = error?.response?.data;
|
|
4735
|
+
const describedBody =
|
|
4736
|
+
body === undefined ? undefined : this.compactDiagnosticPayload(body);
|
|
4737
|
+
|
|
4738
|
+
// And keep the headers' one useful field for the same reason. The
|
|
4739
|
+
// refusal we went to this trouble for was "Request method 'GET' is not
|
|
4740
|
+
// supported", which names the verb that failed and not the ones that
|
|
4741
|
+
// would work; `Allow` is where a servlet puts those.
|
|
4742
|
+
const allow = this.readAllowedMethods(error?.response?.headers);
|
|
4743
|
+
|
|
4656
4744
|
results[route.label] = {
|
|
4657
4745
|
path: route.path,
|
|
4746
|
+
method: verb,
|
|
4658
4747
|
ok: false,
|
|
4659
4748
|
status: status ?? null,
|
|
4660
4749
|
error: message,
|
|
4750
|
+
body: describedBody,
|
|
4751
|
+
allow,
|
|
4661
4752
|
};
|
|
4662
4753
|
|
|
4663
4754
|
this.log.debug(
|
|
4664
|
-
`Roborock cloud schedule probe for ${this.describeDevice(duid)} —
|
|
4755
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${verb} ${route.path} failed${
|
|
4665
4756
|
status ? ` with HTTP ${status}` : ""
|
|
4666
|
-
}: ${message}
|
|
4757
|
+
}: ${message}${
|
|
4758
|
+
describedBody === undefined
|
|
4759
|
+
? ""
|
|
4760
|
+
: ` — the server said: ${JSON.stringify(describedBody)}`
|
|
4761
|
+
}${
|
|
4762
|
+
allow === undefined
|
|
4763
|
+
? ""
|
|
4764
|
+
: ` — the resource allows: ${this.compactDiagnosticPayload(allow)}`
|
|
4765
|
+
}`
|
|
4667
4766
|
);
|
|
4668
4767
|
}
|
|
4669
4768
|
}
|
|
@@ -4721,9 +4820,29 @@ class Roborock {
|
|
|
4721
4820
|
);
|
|
4722
4821
|
|
|
4723
4822
|
if (firstSchedule) {
|
|
4823
|
+
const scenePath = `user/scene/${firstSchedule.id}`;
|
|
4824
|
+
|
|
4825
|
+
await this.probeOneCloudScheduleRoute(
|
|
4826
|
+
duid,
|
|
4827
|
+
{ label: "scene", path: scenePath },
|
|
4828
|
+
results
|
|
4829
|
+
);
|
|
4830
|
+
|
|
4831
|
+
// The reading that arrived is why this second question exists. That GET
|
|
4832
|
+
// came back `400` saying `"Request method 'GET' is not supported"` —
|
|
4833
|
+
// which is not "no such route". A servlet only says that when the path
|
|
4834
|
+
// IS mapped and the verb is not, so the singular scene resource exists
|
|
4835
|
+
// and some other method reaches it. Naming that method is exactly the
|
|
4836
|
+
// thing still missing: we know WHAT a write would have to change (the
|
|
4837
|
+
// `enabled` flag inside the TIMER trigger) and not WHERE to send it.
|
|
4838
|
+
//
|
|
4839
|
+
// OPTIONS is how a resource is asked that question without attempting
|
|
4840
|
+
// an answer. It is defined as safe, it carries no body, and it cannot
|
|
4841
|
+
// alter a schedule — so it is the one step toward a write that does not
|
|
4842
|
+
// require the guess this thread has twice refused to make.
|
|
4724
4843
|
await this.probeOneCloudScheduleRoute(
|
|
4725
4844
|
duid,
|
|
4726
|
-
{ label: "
|
|
4845
|
+
{ label: "sceneMethods", path: scenePath, method: "options" },
|
|
4727
4846
|
results
|
|
4728
4847
|
);
|
|
4729
4848
|
}
|
|
@@ -5338,7 +5457,7 @@ class Roborock {
|
|
|
5338
5457
|
* Fetch the current SCMap and derive which room the robot is physically
|
|
5339
5458
|
* inside (currentPose ray-cast against the per-room boundary chains).
|
|
5340
5459
|
* Called from the B01 status loop while the robot is actively cleaning;
|
|
5341
|
-
* throttled on attempts (min
|
|
5460
|
+
* throttled on attempts (min 10s gap), single-flight per device, and
|
|
5342
5461
|
* disabled entirely with the enableLiveRoomTracking=false config option.
|
|
5343
5462
|
*
|
|
5344
5463
|
* On a room CHANGE the cached last v1 status is re-broadcast through
|