homebridge-roborock-matter 3.24.2 → 3.25.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 +14 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/roborockLib/lib/parseCloudSceneSchedules.js +15 -1
- package/roborockLib/roborockAPI.js +105 -57
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.25.0
|
|
4
|
+
|
|
5
|
+
**The schedule probe now measures whether a cloud schedule is a resource the plugin could ever write to.**
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
3
17
|
## 3.24.2
|
|
4
18
|
|
|
5
19
|
**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. 1838 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 1838 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.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": {
|
|
@@ -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,83 @@ 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
|
+
results[route.label] = {
|
|
4657
|
+
path: route.path,
|
|
4658
|
+
ok: false,
|
|
4659
|
+
status: status ?? null,
|
|
4660
|
+
error: message,
|
|
4661
|
+
};
|
|
4662
|
+
|
|
4663
|
+
this.log.debug(
|
|
4664
|
+
`Roborock cloud schedule probe for ${this.describeDevice(duid)} — GET ${route.path} failed${
|
|
4665
|
+
status ? ` with HTTP ${status}` : ""
|
|
4666
|
+
}: ${message}`
|
|
4667
|
+
);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
|
|
4593
4671
|
async probeCloudScheduleRoutes(duid) {
|
|
4594
4672
|
if (!duid || !this.config?.debug || !this.api) {
|
|
4595
4673
|
return undefined;
|
|
@@ -4619,65 +4697,35 @@ class Roborock {
|
|
|
4619
4697
|
const results = {};
|
|
4620
4698
|
|
|
4621
4699
|
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 ?? "");
|
|
4700
|
+
await this.probeOneCloudScheduleRoute(duid, route, results);
|
|
4701
|
+
}
|
|
4667
4702
|
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4703
|
+
// A HomeKit switch over these schedules has to WRITE, and the only write
|
|
4704
|
+
// route measured on this client is `user/scene/{id}/execute`, which RUNS a
|
|
4705
|
+
// scene rather than enabling one. The reporter's off-and-on measurement
|
|
4706
|
+
// narrowed what such a write would have to change — the `enabled` flag
|
|
4707
|
+
// inside the TIMER trigger, not the scene-level one — but not where to
|
|
4708
|
+
// send it, and a guessed write endpoint against a live account does not
|
|
4709
|
+
// fail politely.
|
|
4710
|
+
//
|
|
4711
|
+
// So measure the one thing that costs nothing: whether the singular scene
|
|
4712
|
+
// resource answers at all. A REST resource that answers GET is the only
|
|
4713
|
+
// defensible candidate for a later write, its answer is the payload shape
|
|
4714
|
+
// such a write would have to send back, and a 404 rules it out for free.
|
|
4715
|
+
//
|
|
4716
|
+
// Exactly one scene, deliberately: this is a shape measurement, not an
|
|
4717
|
+
// inventory, and an account with nine schedules must not become nine
|
|
4718
|
+
// requests. It stays inside the once-per-robot-per-session guard above.
|
|
4719
|
+
const [firstSchedule] = parseCloudSceneSchedules(
|
|
4720
|
+
/** @type {{response?: unknown}} */ (results.scenes)?.response
|
|
4721
|
+
);
|
|
4674
4722
|
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4723
|
+
if (firstSchedule) {
|
|
4724
|
+
await this.probeOneCloudScheduleRoute(
|
|
4725
|
+
duid,
|
|
4726
|
+
{ label: "scene", path: `user/scene/${firstSchedule.id}` },
|
|
4727
|
+
results
|
|
4728
|
+
);
|
|
4681
4729
|
}
|
|
4682
4730
|
|
|
4683
4731
|
await this.updateRoborockDiagnostics(
|