homebridge-roborock-matter 3.24.2 → 3.25.1
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 +28 -0
- package/README.md +2 -2
- package/config.schema.json +1 -1
- package/package.json +1 -1
- package/roborockLib/lib/parseCloudSceneSchedules.js +15 -1
- package/roborockLib/roborockAPI.js +125 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.25.1
|
|
4
|
+
|
|
5
|
+
**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.**
|
|
6
|
+
|
|
7
|
+
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`.
|
|
8
|
+
|
|
9
|
+
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`.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
No behaviour outside the diagnostic changes, and the probe is still debug-only, GET-only, once per robot per session, and unable to throw.
|
|
16
|
+
|
|
17
|
+
## 3.25.0
|
|
18
|
+
|
|
19
|
+
**The schedule probe now measures whether a cloud schedule is a resource the plugin could ever write to.**
|
|
20
|
+
|
|
21
|
+
Issue #22's reporter switched two of his three app schedules off, restarted, and sent the reading without saying which two. The log named them, and it named something more useful than that: every scene-level `enabled` stayed `true`, and the flag his app had actually flipped was `enabled` inside each schedule's own TIMER trigger, nested a level deeper. So a HomeKit switch over these schedules would have to rewrite that nested field — not the scene's own.
|
|
22
|
+
|
|
23
|
+
Knowing what to write is not knowing where to send it. The only write route measured on this client is `user/scene/{id}/execute`, which runs a scene rather than enabling one, and a guessed write endpoint against somebody's live account does not fail politely — it edits or deletes a schedule they rely on.
|
|
24
|
+
|
|
25
|
+
So this release measures the one remaining thing that costs nothing: whether the singular scene resource answers at all. When the device route reports a timer-driven scene, the probe reads that one scene by id. A resource that answers GET is the only defensible candidate for a later write, and its answer is the payload shape such a write would have to send back; a 404 rules it out for free.
|
|
26
|
+
|
|
27
|
+
The constraints are unchanged, because they are what make shipping a measurement to thousands of installations defensible: debug logging only, GET only, once per robot per session, cannot throw, credential-shaped fields redacted. Exactly one scene is read rather than all of them — this is a shape measurement, not an inventory, and an account with nine schedules must not become nine requests.
|
|
28
|
+
|
|
29
|
+
Still a diagnostic, still not a feature. Reading these schedules is solved; switching them is not, and it will not be built on a guess.
|
|
30
|
+
|
|
3
31
|
## 3.24.2
|
|
4
32
|
|
|
5
33
|
**Shutting down while LAN discovery was listening left a UDP socket open and every caller waiting on it hung.**
|
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. 1847 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 1847 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.25.1",
|
|
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": {
|
|
@@ -155,11 +155,25 @@ function describeCron(cron) {
|
|
|
155
155
|
*/
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
+
* Which of the two enable flags the app actually uses is MEASURED, not
|
|
159
|
+
* assumed, and it matters to anything that would ever write one back.
|
|
160
|
+
*
|
|
161
|
+
* The reporter in issue #22 switched two of his three schedules off in the
|
|
162
|
+
* Roborock app and sent the probe's reading. Every scene-level `enabled`
|
|
163
|
+
* stayed `true`; the flag that changed was `enabled` inside each TIMER
|
|
164
|
+
* trigger's own nested `param` string. So the app toggles the TRIGGER, and a
|
|
165
|
+
* schedule switch would have to rewrite that nested JSON rather than the
|
|
166
|
+
* scene's own field.
|
|
167
|
+
*
|
|
168
|
+
* Both flags are still modelled, because a scene disabled at scene level is a
|
|
169
|
+
* state the cloud can express and `active` has to account for it.
|
|
170
|
+
*
|
|
158
171
|
* @typedef {object} CloudSceneSchedule
|
|
159
172
|
* @property {string} id scene id
|
|
160
173
|
* @property {string|null} name scene name as shown in the app
|
|
161
174
|
* @property {string|null} type scene type, e.g. `WORKFLOW`
|
|
162
|
-
* @property {boolean} enabled scene-level enable flag
|
|
175
|
+
* @property {boolean} enabled scene-level enable flag — measured to stay
|
|
176
|
+
* `true` when the app switches a schedule off
|
|
163
177
|
* @property {boolean} active scene enabled AND at least one timer enabled
|
|
164
178
|
* @property {CloudSceneTrigger[]} triggers timer triggers on the scene
|
|
165
179
|
* @property {CloudSceneAction[]} actions device actions the scene performs
|
|
@@ -23,6 +23,7 @@ const roborockCrypto = require("./lib/roborockCrypto");
|
|
|
23
23
|
const { METHOD_REFUSED_CODE } = require("./lib/describeReplyRefusal");
|
|
24
24
|
const {
|
|
25
25
|
summariseCloudSceneSchedules,
|
|
26
|
+
parseCloudSceneSchedules,
|
|
26
27
|
} = require("./lib/parseCloudSceneSchedules");
|
|
27
28
|
const b01Q7Adapter = require("./lib/b01Q7Adapter");
|
|
28
29
|
|
|
@@ -4590,6 +4591,102 @@ class Roborock {
|
|
|
4590
4591
|
}
|
|
4591
4592
|
}
|
|
4592
4593
|
|
|
4594
|
+
/**
|
|
4595
|
+
* GET one candidate cloud schedule route, record it, and log what came back.
|
|
4596
|
+
*
|
|
4597
|
+
* Extracted so that every route — the two candidates and the singular scene
|
|
4598
|
+
* resource probed after them — shares one error path. It never throws: this
|
|
4599
|
+
* rides along on a live poll, and a route that does not exist is a
|
|
4600
|
+
* measurement rather than a fault, so a failure is recorded and logged at
|
|
4601
|
+
* debug level instead of surfacing.
|
|
4602
|
+
*
|
|
4603
|
+
* @param {string} duid robot the reading belongs to
|
|
4604
|
+
* @param {{label: string, path: string}} route label to file it under, and
|
|
4605
|
+
* the path to read
|
|
4606
|
+
* @param {Record<string, unknown>} results accumulator, written in place
|
|
4607
|
+
* @returns {Promise<void>}
|
|
4608
|
+
*/
|
|
4609
|
+
async probeOneCloudScheduleRoute(duid, route, results) {
|
|
4610
|
+
try {
|
|
4611
|
+
const response = await this.api.get(route.path);
|
|
4612
|
+
// Roborock wraps most answers in `{api,result,status,success}`. Keep
|
|
4613
|
+
// the envelope only when there is no `result` to unwrap, so the log
|
|
4614
|
+
// shows the payload rather than the wrapper.
|
|
4615
|
+
const payload =
|
|
4616
|
+
response?.data?.result === undefined
|
|
4617
|
+
? response?.data
|
|
4618
|
+
: response.data.result;
|
|
4619
|
+
|
|
4620
|
+
// Decode BEFORE compacting. `compactDiagnosticPayload` caps strings at
|
|
4621
|
+
// 500 characters and arrays at 8 entries; measured on the real scenes
|
|
4622
|
+
// answer, that cut every schedule mid-task and would drop a ninth
|
|
4623
|
+
// schedule entirely. The raw answer is the only place the measurement
|
|
4624
|
+
// is intact.
|
|
4625
|
+
const decoded = this.describeCloudScheduleAnswer(payload);
|
|
4626
|
+
|
|
4627
|
+
results[route.label] = {
|
|
4628
|
+
path: route.path,
|
|
4629
|
+
ok: true,
|
|
4630
|
+
response: payload,
|
|
4631
|
+
schedules: decoded.length > 0 ? decoded : undefined,
|
|
4632
|
+
};
|
|
4633
|
+
|
|
4634
|
+
this.log.debug(
|
|
4635
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — GET ${route.path} answered: ${JSON.stringify(
|
|
4636
|
+
this.compactDiagnosticPayload(payload)
|
|
4637
|
+
)}`
|
|
4638
|
+
);
|
|
4639
|
+
|
|
4640
|
+
if (decoded.length > 0) {
|
|
4641
|
+
const [headline, ...entries] = decoded;
|
|
4642
|
+
this.log.debug(
|
|
4643
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${route.path} carries ${headline}:`
|
|
4644
|
+
);
|
|
4645
|
+
for (const entry of entries) {
|
|
4646
|
+
this.log.debug(
|
|
4647
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${entry}`
|
|
4648
|
+
);
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
} catch (error) {
|
|
4652
|
+
const status = error?.response?.status;
|
|
4653
|
+
const message =
|
|
4654
|
+
error instanceof Error ? error.message : String(error ?? "");
|
|
4655
|
+
|
|
4656
|
+
// The status alone does not measure the route, and the reporter's answer
|
|
4657
|
+
// is why: `user/scene/{id}` came back `400`, not `404`. A 404 would have
|
|
4658
|
+
// ruled the resource out; a 400 says the server routed the request and
|
|
4659
|
+
// then rejected it, and only its own body says whether that is "no such
|
|
4660
|
+
// route", "wrong method" or "that scene is not yours". Axios flattens all
|
|
4661
|
+
// of it into `Request failed with status code 400`, which carries nothing.
|
|
4662
|
+
//
|
|
4663
|
+
// So keep the body. It goes through the same compaction and redaction as
|
|
4664
|
+
// a successful answer, because an error envelope is no more ours to print
|
|
4665
|
+
// blindly than a successful one.
|
|
4666
|
+
const body = error?.response?.data;
|
|
4667
|
+
const describedBody =
|
|
4668
|
+
body === undefined ? undefined : this.compactDiagnosticPayload(body);
|
|
4669
|
+
|
|
4670
|
+
results[route.label] = {
|
|
4671
|
+
path: route.path,
|
|
4672
|
+
ok: false,
|
|
4673
|
+
status: status ?? null,
|
|
4674
|
+
error: message,
|
|
4675
|
+
body: describedBody,
|
|
4676
|
+
};
|
|
4677
|
+
|
|
4678
|
+
this.log.debug(
|
|
4679
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — GET ${route.path} failed${
|
|
4680
|
+
status ? ` with HTTP ${status}` : ""
|
|
4681
|
+
}: ${message}${
|
|
4682
|
+
describedBody === undefined
|
|
4683
|
+
? ""
|
|
4684
|
+
: ` — the server said: ${JSON.stringify(describedBody)}`
|
|
4685
|
+
}`
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4593
4690
|
async probeCloudScheduleRoutes(duid) {
|
|
4594
4691
|
if (!duid || !this.config?.debug || !this.api) {
|
|
4595
4692
|
return undefined;
|
|
@@ -4619,65 +4716,35 @@ class Roborock {
|
|
|
4619
4716
|
const results = {};
|
|
4620
4717
|
|
|
4621
4718
|
for (const route of routes) {
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
// Roborock wraps most answers in `{api,result,status,success}`. Keep
|
|
4625
|
-
// the envelope only when there is no `result` to unwrap, so the log
|
|
4626
|
-
// shows the payload rather than the wrapper.
|
|
4627
|
-
const payload =
|
|
4628
|
-
response?.data?.result === undefined
|
|
4629
|
-
? response?.data
|
|
4630
|
-
: response.data.result;
|
|
4631
|
-
|
|
4632
|
-
// Decode BEFORE compacting. `compactDiagnosticPayload` caps strings at
|
|
4633
|
-
// 500 characters and arrays at 8 entries; measured on the real scenes
|
|
4634
|
-
// answer, that cut every schedule mid-task and would drop a ninth
|
|
4635
|
-
// schedule entirely. The raw answer is the only place the measurement
|
|
4636
|
-
// is intact.
|
|
4637
|
-
const decoded = this.describeCloudScheduleAnswer(payload);
|
|
4638
|
-
|
|
4639
|
-
results[route.label] = {
|
|
4640
|
-
path: route.path,
|
|
4641
|
-
ok: true,
|
|
4642
|
-
response: payload,
|
|
4643
|
-
schedules: decoded.length > 0 ? decoded : undefined,
|
|
4644
|
-
};
|
|
4645
|
-
|
|
4646
|
-
this.log.debug(
|
|
4647
|
-
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — GET ${route.path} answered: ${JSON.stringify(
|
|
4648
|
-
this.compactDiagnosticPayload(payload)
|
|
4649
|
-
)}`
|
|
4650
|
-
);
|
|
4651
|
-
|
|
4652
|
-
if (decoded.length > 0) {
|
|
4653
|
-
const [headline, ...entries] = decoded;
|
|
4654
|
-
this.log.debug(
|
|
4655
|
-
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${route.path} carries ${headline}:`
|
|
4656
|
-
);
|
|
4657
|
-
for (const entry of entries) {
|
|
4658
|
-
this.log.debug(
|
|
4659
|
-
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — ${entry}`
|
|
4660
|
-
);
|
|
4661
|
-
}
|
|
4662
|
-
}
|
|
4663
|
-
} catch (error) {
|
|
4664
|
-
const status = error?.response?.status;
|
|
4665
|
-
const message =
|
|
4666
|
-
error instanceof Error ? error.message : String(error ?? "");
|
|
4719
|
+
await this.probeOneCloudScheduleRoute(duid, route, results);
|
|
4720
|
+
}
|
|
4667
4721
|
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4722
|
+
// A HomeKit switch over these schedules has to WRITE, and the only write
|
|
4723
|
+
// route measured on this client is `user/scene/{id}/execute`, which RUNS a
|
|
4724
|
+
// scene rather than enabling one. The reporter's off-and-on measurement
|
|
4725
|
+
// narrowed what such a write would have to change — the `enabled` flag
|
|
4726
|
+
// inside the TIMER trigger, not the scene-level one — but not where to
|
|
4727
|
+
// send it, and a guessed write endpoint against a live account does not
|
|
4728
|
+
// fail politely.
|
|
4729
|
+
//
|
|
4730
|
+
// So measure the one thing that costs nothing: whether the singular scene
|
|
4731
|
+
// resource answers at all. A REST resource that answers GET is the only
|
|
4732
|
+
// defensible candidate for a later write, its answer is the payload shape
|
|
4733
|
+
// such a write would have to send back, and a 404 rules it out for free.
|
|
4734
|
+
//
|
|
4735
|
+
// Exactly one scene, deliberately: this is a shape measurement, not an
|
|
4736
|
+
// inventory, and an account with nine schedules must not become nine
|
|
4737
|
+
// requests. It stays inside the once-per-robot-per-session guard above.
|
|
4738
|
+
const [firstSchedule] = parseCloudSceneSchedules(
|
|
4739
|
+
/** @type {{response?: unknown}} */ (results.scenes)?.response
|
|
4740
|
+
);
|
|
4674
4741
|
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4742
|
+
if (firstSchedule) {
|
|
4743
|
+
await this.probeOneCloudScheduleRoute(
|
|
4744
|
+
duid,
|
|
4745
|
+
{ label: "scene", path: `user/scene/${firstSchedule.id}` },
|
|
4746
|
+
results
|
|
4747
|
+
);
|
|
4681
4748
|
}
|
|
4682
4749
|
|
|
4683
4750
|
await this.updateRoborockDiagnostics(
|
|
@@ -5290,7 +5357,7 @@ class Roborock {
|
|
|
5290
5357
|
* Fetch the current SCMap and derive which room the robot is physically
|
|
5291
5358
|
* inside (currentPose ray-cast against the per-room boundary chains).
|
|
5292
5359
|
* Called from the B01 status loop while the robot is actively cleaning;
|
|
5293
|
-
* throttled on attempts (min
|
|
5360
|
+
* throttled on attempts (min 10s gap), single-flight per device, and
|
|
5294
5361
|
* disabled entirely with the enableLiveRoomTracking=false config option.
|
|
5295
5362
|
*
|
|
5296
5363
|
* On a room CHANGE the cached last v1 status is re-broadcast through
|