homebridge-roborock-matter 3.4.7 → 3.4.9

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.4.9
4
+
5
+ - **A live-room miss now says where the rooms actually are.** Two Q7s produced position cells around 22,000 while a Roborock map is a couple of thousand cells across at most — so those robots were never "between rooms", their computed position was nowhere near the map. One of them reported x exactly equal to y, which is arithmetic rather than a place a robot stood. The position on its own cannot separate a unit mismatch from a wrong origin, so the miss line now carries the range the room outlines occupy plus the map origin and resolution the transform used. This changes no behaviour; it turns the next log from a hypothesis into a measurement. The bounding box is computed only on the failure path, so a run that resolves every position pays nothing.
6
+
7
+ ## 3.4.8
8
+
9
+ **Selecting "Vacuum" and getting a vacuum-and-mop was not a display bug — one timed-out command cancelled the one that mattered.** skmzwanke reported in [#8](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/8) that he selected Vacuum for a single room and the robot mopped it anyway, and his 3.4.5 log names the cause outright.
10
+
11
+ On a v1 robot the difference between "Vacuum" and "Vacuum and mop" **is** the water-box mode: choosing Vacuum sends water-box OFF. Fan power only picks a suction level within the chosen mode. The prep sequence sent fan power first and, if that command timed out, returned — so the water command was never sent at all. In his log, `set_custom_mode` timed out after two seconds, `set_water_box_custom_mode` never appeared, and the robot kept the mopping setting it already had from the Roborock app. A cosmetic command that did not answer in time cancelled the one carrying the user's actual choice.
12
+
13
+ - **The water command now goes out first, and no command in the sequence is cancelled by another's failure.** Dropping the early return cannot delay the start: the caller already races the whole prep against its own timeout, so the early return was buying latency protection that was paid for one level up.
14
+ - **A partial apply is now announced at warn level**, naming the robot and what was not confirmed. It was a debug line before, which meant that on a default log level the robot simply did the wrong job in silence while the Matter tile reported the mode that had been selected. That mismatch took two rounds of #8 to pin down.
15
+ - **The rule is enumerated over the sequence, not over the two commands in it today:** no clean-mode prep command may return out of the middle of the sequence. A third setting added later is covered by construction.
16
+
3
17
  ## 3.4.7
4
18
 
5
19
  **The diagnostic report told Q7 owners their robot had tried to reach the LAN and failed. It never tried.** Following [#7](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/7), jawnlydon unpaired his robot from Apple Home, uninstalled the plugin, reinstalled it and paired fresh — and his report still read `markedRemote: true`, `remoteReason: marked-remote-after-connect-failure`, `connectionStatus: Cloud fallback`, "usually because LAN TCP was not connected at that moment". His `roborock.vacuum.sc05` speaks the B01 protocol, which has no LAN request surface at all: the plugin marks these robots cloud-only at startup precisely so it never opens a local socket to them. Every line above described a network fault that could not occur, and he spent an evening chasing it.
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. 419 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. 463 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.4.7",
3
+ "version": "3.4.9",
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": {
@@ -504,7 +504,9 @@ function resolveLiveRoomId(liveState) {
504
504
  * @returns {{roomId: number | null,
505
505
  * reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines" | "pose-outside-outlines",
506
506
  * outlineCount: number,
507
- * cell: {x: number, y: number} | null}}
507
+ * cell: {x: number, y: number} | null,
508
+ * outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
509
+ * head?: {minX: number, minY: number, resolution: number}}}
508
510
  */
509
511
  function describeLiveRoomResolution(liveState) {
510
512
  const head = liveState?.head;
@@ -541,9 +543,44 @@ function describeLiveRoomResolution(liveState) {
541
543
  reason: "pose-outside-outlines",
542
544
  outlineCount,
543
545
  cell,
546
+ outlineBounds: outlineBoundingBox(chains),
547
+ head: { minX: head.minX, minY: head.minY, resolution },
544
548
  };
545
549
  }
546
550
 
551
+ /**
552
+ * Bounding box of every room outline, in the same cell space the
553
+ * point-in-polygon test uses.
554
+ *
555
+ * Field logs showed two Q7s reporting position cells around 22,000 while a
556
+ * Roborock map is at most a couple of thousand cells across — so the position
557
+ * is not "between rooms", it is nowhere near the map. Whether that is a unit
558
+ * mismatch (pose in millimetres against a resolution in metres) or a wrong
559
+ * origin cannot be told from the position alone: it needs the range the
560
+ * outlines actually occupy. Printing both next to each other turns a guess
561
+ * into a measurement.
562
+ *
563
+ * @param {Array<{points: Array<{x: number, y: number}>}>} chains
564
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number} | null}
565
+ */
566
+ function outlineBoundingBox(chains) {
567
+ let minX = Infinity;
568
+ let minY = Infinity;
569
+ let maxX = -Infinity;
570
+ let maxY = -Infinity;
571
+
572
+ for (const chain of chains) {
573
+ for (const point of chain.points || []) {
574
+ if (point.x < minX) minX = point.x;
575
+ if (point.y < minY) minY = point.y;
576
+ if (point.x > maxX) maxX = point.x;
577
+ if (point.y > maxY) maxY = point.y;
578
+ }
579
+ }
580
+
581
+ return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
582
+ }
583
+
547
584
  /**
548
585
  * Standard ray-casting point-in-polygon test over a room boundary chain.
549
586
  * @param {number} x @param {number} y
@@ -95,6 +95,34 @@ const B01_LIVE_ROOM_MISS_REASONS = {
95
95
  "the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)",
96
96
  };
97
97
 
98
+ /**
99
+ * The outline range and map origin, appended to a live-room miss.
100
+ *
101
+ * A position cell on its own cannot distinguish "the robot is between rooms"
102
+ * from "the position was computed in the wrong units" — and the field logs
103
+ * showed cells near 22,000 where a Roborock map is a couple of thousand cells
104
+ * at most. Printing the range the outlines occupy, plus the origin and
105
+ * resolution the transform used, makes the difference measurable from one log
106
+ * line instead of inferable from none.
107
+ *
108
+ * @param {{outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
109
+ * head?: {minX: number, minY: number, resolution: number}}} resolution
110
+ * @returns {string}
111
+ */
112
+ function describeOutlineBounds(resolution) {
113
+ const bounds = resolution?.outlineBounds;
114
+ if (!bounds) {
115
+ return "";
116
+ }
117
+
118
+ const head = resolution.head;
119
+ const origin = head
120
+ ? `, map origin ${head.minX},${head.minY} at ${head.resolution}/cell`
121
+ : "";
122
+
123
+ return `, outlines span ${Math.round(bounds.minX)}-${Math.round(bounds.maxX)} x ${Math.round(bounds.minY)}-${Math.round(bounds.maxY)}${origin}`;
124
+ }
125
+
98
126
  const B01_STATUS_TICK_MS = 15000;
99
127
  const B01_STATUS_FORCED_GAP_MS = 1500;
100
128
  const B01_STATUS_ACTIVE_GAP_MS = 12000;
@@ -2355,6 +2383,49 @@ class Roborock {
2355
2383
  return;
2356
2384
  }
2357
2385
 
2386
+ // The water command goes first, and no failure below cancels a later
2387
+ // command.
2388
+ //
2389
+ // On a v1 robot the difference between "Vacuum" and "Vacuum and mop" IS
2390
+ // the water-box mode: selecting Vacuum sends water-box OFF. Fan power is a
2391
+ // suction level *within* the chosen mode. The fan command used to run
2392
+ // first and, on timeout, return — skipping the water command entirely. So
2393
+ // skmzwanke selected Vacuum in Apple Home, the fan command timed out after
2394
+ // two seconds, the water command was never sent, and his Saros 10 ran a
2395
+ // vacuum-and-mop over the room he had asked to be vacuumed (#8). A
2396
+ // cosmetic command that did not answer in time cancelled the one that
2397
+ // carried the user's actual choice.
2398
+ //
2399
+ // Dropping the early return cannot run the start command late: the caller
2400
+ // races this whole sequence against its own prep timeout, which is what
2401
+ // bounds the delay. The early return was buying latency protection that
2402
+ // was already paid for one level up.
2403
+ const failedCommands = [];
2404
+
2405
+ if (Number.isInteger(settings?.waterBoxMode)) {
2406
+ const waterCommands = this.getMatterWaterModeCommandCandidates(duid);
2407
+
2408
+ if (waterCommands.length === 0) {
2409
+ this.log.debug(
2410
+ `Matter clean mode requested water mode ${settings.waterBoxMode} for ${duid}, but no supported Roborock water command was detected.`
2411
+ );
2412
+ } else {
2413
+ try {
2414
+ await this.runFirstMatterSettingCommand(
2415
+ duid,
2416
+ waterCommands,
2417
+ settings.waterBoxMode,
2418
+ commandOptions
2419
+ );
2420
+ } catch (error) {
2421
+ failedCommands.push("water mode");
2422
+ this.log.debug(
2423
+ `Matter clean mode water commands failed for ${duid}; continuing with start command. ${error.message || error}`
2424
+ );
2425
+ }
2426
+ }
2427
+ }
2428
+
2358
2429
  if (
2359
2430
  Number.isInteger(settings?.fanPower) &&
2360
2431
  this.getMatterCleanModeCapabilities(duid).canControlFanPower
@@ -2372,40 +2443,21 @@ class Roborock {
2372
2443
  "set_custom_mode",
2373
2444
  error
2374
2445
  );
2375
- if (this.isMatterSettingTimeoutError(error)) {
2376
- this.log.debug(
2377
- `Matter clean mode fan command timed out for ${duid}; skipping remaining clean-mode prep and continuing with start command. ${error.message || error}`
2378
- );
2379
- return;
2380
- }
2446
+ failedCommands.push("suction level");
2381
2447
  this.log.debug(
2382
2448
  `Matter clean mode fan command failed for ${duid}; continuing with start command. ${error.message || error}`
2383
2449
  );
2384
2450
  }
2385
2451
  }
2386
2452
 
2387
- if (!Number.isInteger(settings?.waterBoxMode)) {
2388
- return;
2389
- }
2390
-
2391
- const waterCommands = this.getMatterWaterModeCommandCandidates(duid);
2392
- if (waterCommands.length === 0) {
2393
- this.log.debug(
2394
- `Matter clean mode requested water mode ${settings.waterBoxMode} for ${duid}, but no supported Roborock water command was detected.`
2395
- );
2396
- return;
2397
- }
2398
-
2399
- try {
2400
- await this.runFirstMatterSettingCommand(
2401
- duid,
2402
- waterCommands,
2403
- settings.waterBoxMode,
2404
- commandOptions
2405
- );
2406
- } catch (error) {
2407
- this.log.debug(
2408
- `Matter clean mode water commands failed for ${duid}; continuing with start command. ${error.message || error}`
2453
+ // At warn level on purpose. The clean is about to start and the Matter
2454
+ // tile will report the mode the user selected, so a silent partial apply
2455
+ // leaves the tile stating something the robot is not doing. That mismatch
2456
+ // is exactly what took two rounds of #8 to pin down, and the log is where
2457
+ // the next person will look.
2458
+ if (failedCommands.length > 0) {
2459
+ this.log.warn(
2460
+ `Roborock did not confirm the ${failedCommands.join(" and ")} for ${this.describeDevice(duid)} before starting; the robot may keep its previous settings for this run, so the clean may not match the mode selected in your controller.`
2409
2461
  );
2410
2462
  }
2411
2463
  }
@@ -2521,11 +2573,6 @@ class Roborock {
2521
2573
  ].some((pattern) => message.includes(pattern));
2522
2574
  }
2523
2575
 
2524
- isMatterSettingTimeoutError(error) {
2525
- const message = `${error?.message || error || ""}`.toLowerCase();
2526
- return message.includes("request") && message.includes("timed out after");
2527
- }
2528
-
2529
2576
  /**
2530
2577
  * Self-healing capability detection for periodic poll commands: once a
2531
2578
  * robot definitively answers a request with an "unsupported"-class error,
@@ -4272,7 +4319,7 @@ class Roborock {
4272
4319
  // because they call for different fixes.
4273
4320
  liveState.unresolvedPoseCount =
4274
4321
  (liveState.unresolvedPoseCount || 0) + 1;
4275
- const message = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (attempt ${liveState.unresolvedPoseCount} this run, ${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}).`;
4322
+ const message = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (attempt ${liveState.unresolvedPoseCount} this run, ${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}${describeOutlineBounds(resolution2)}).`;
4276
4323
  if (liveState.unresolvedPoseCount % 5 === 0) {
4277
4324
  this.log.info(message);
4278
4325
  } else {