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 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. 1831 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. 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 1831 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
+ 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.24.2",
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
- try {
4623
- const response = await this.api.get(route.path);
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
- results[route.label] = {
4669
- path: route.path,
4670
- ok: false,
4671
- status: status ?? null,
4672
- error: message,
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
- this.log.debug(
4676
- `Roborock cloud schedule probe for ${this.describeDevice(duid)} — GET ${route.path} failed${
4677
- status ? ` with HTTP ${status}` : ""
4678
- }: ${message}`
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(