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 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. 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. 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 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 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
 
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "enableLiveRoomTracking": {
78
78
  "title": "Enable Live Room Tracking",
79
- "description": "While a B01/Q7-series robot is actively cleaning, periodically fetch the robot's map position (every ~20 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>'. Requires Matter Room/Map Selection. Disable to avoid the extra map traffic.",
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.24.2",
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
- 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 ?? "");
4719
+ await this.probeOneCloudScheduleRoute(duid, route, results);
4720
+ }
4667
4721
 
4668
- results[route.label] = {
4669
- path: route.path,
4670
- ok: false,
4671
- status: status ?? null,
4672
- error: message,
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
- 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
- }
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 20s gap), single-flight per device, and
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