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 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. 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.
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 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.
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
 
@@ -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.25.0",
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 only.** Nothing here can change a schedule. The Hawk interceptor
4564
- * signs an empty body (`roborockAPI.js` request interceptor), so a
4565
- * body-bearing write would not authenticate anyway — a read does.
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
- * GET one candidate cloud schedule route, record it, and log what came back.
4597
+ * Pluck the one response header that says which methods a resource takes.
4596
4598
  *
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.
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 file it under, and
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.get(route.path);
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)} — GET ${route.path} answered: ${JSON.stringify(
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)} — GET ${route.path} failed${
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: "scene", path: `user/scene/${firstSchedule.id}` },
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 20s gap), single-flight per device, and
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