homebridge-roborock-matter 3.10.2 → 3.11.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 +4 -2
- package/package.json +1 -1
- package/roborockLib/lib/b01Q7Adapter.js +92 -3
- package/roborockLib/lib/vacuum.js +15 -6
- package/roborockLib/roborockAPI.js +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.11.0
|
|
4
|
+
|
|
5
|
+
**A Q7 said it was between rooms two hundred and twenty-six times during one clean. It was in the bedroom the whole time.**
|
|
6
|
+
|
|
7
|
+
I caught a run on my own robots and pulled the numbers rather than the impressions. One Q7, 47 minutes, 227 live-room fetches. 226 of them placed it at cell 22280,22100 — the same cell every time — while the room outlines on that map span 38 to 293. The pose behind it was exactly (1100, 1100), which is the same constant two other people's Q7s reported back in August. The remaining fetches resolved Stue, then Gang, then Soveværelse, in the order the robot actually moved.
|
|
8
|
+
|
|
9
|
+
So the robot does send a real position. It just serves a placeholder in between, and every one of those was being written up as "the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)". That sentence was wrong twice over: the robot was not between rooms, and there was nothing anyone could do about it. It also fed the miss counter, which is how a robot cleaning one bedroom produced "after 46 unresolved position(s)".
|
|
10
|
+
|
|
11
|
+
A position further outside the map than the map is wide is now recognised for what it is and named as a placeholder, once per run at a level you see and quietly thereafter. The test is geometry rather than the number 1100: the robot cannot be somewhere it has never mapped, which stays true if Roborock picks a different constant. A robot genuinely outside every outline — a doorway, a hallway nobody named, a strip the outlines do not cover — is still a real miss and still counts as one, because that is the case the miss line exists for. Classic S- and Q-series robots resolve every fetch and are untouched.
|
|
12
|
+
|
|
13
|
+
Two things fall out of it. The room still updates on a Q7, on the fetches that carry a true position; nothing about the tile changes. And a resolved room now prints the cell it resolved at, so a working position and a failing one can be compared in one log instead of across two field sessions — which is what this one cost.
|
|
14
|
+
|
|
15
|
+
**Also: fifty log lines a minute, per robot, that could never mean anything.** With debug on, every known status attribute produced `Skipping known get_status attribute without a Homebridge state object` on every poll. That check dates from this library's ioBroker origins, where the object it looks for exists; under Homebridge it never does, so the branch fired for every attribute forever and reported only that the plugin is not ioBroker. On my own server the log ring had shrunk to ninety minutes — the window you need when something real goes wrong. It is gone. An attribute nobody has mapped yet is still named once with its value, which is the half that carries information.
|
|
16
|
+
|
|
3
17
|
## 3.10.2
|
|
4
18
|
|
|
5
19
|
**You asked for vacuum-only, the robot mopped, and Apple Home showed vacuum anyway — because the plugin believed a command it had already logged that it lost.**
|
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. 1134 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
|
|
|
@@ -79,6 +79,8 @@ Progress stays honest: a room is only shown as _completed_ once the robot was ac
|
|
|
79
79
|
|
|
80
80
|
While a robot is actively cleaning, the plugin fetches its live position from the map channel (the first room of a run goes out immediately, then ~10 s apart, active runs only, nothing while docked or paused) and publishes the room it is inside as the Matter Service Area `currentArea`. Both robot generations are covered: **B01/Q7** robots via the encrypted SCMap protobuf (position ray-cast against per-room boundary outlines), **classic S/Q-series** robots via the RRMap segment grid (position resolved against per-pixel room segments — a single-byte lookup on the raw map buffer, ~1 µs per check).
|
|
81
81
|
|
|
82
|
+
**A B01/Q7 robot answers most of those fetches with a placeholder rather than its position, and that is normal.** Measured over a 47-minute clean, 227 fetches: 226 returned exactly the same cell, far outside the map the robot itself had built, while the rest resolved real rooms in the order the robot moved through them. So the room still updates on a Q7 — just on the fetches that carry a true position, which arrive alongside the robot's own map uploads rather than on every poll. A placeholder is now named as one instead of being reported as "the robot is between rooms", and it is said once per run rather than every ten seconds. Classic S/Q-series robots are unaffected and resolve every fetch.
|
|
83
|
+
|
|
82
84
|
</details>
|
|
83
85
|
|
|
84
86
|
## Suction modes (optional)
|
|
@@ -215,7 +217,7 @@ The complete path — robot → plugin → Homebridge → matter.js store — wa
|
|
|
215
217
|
|
|
216
218
|
## Contributing
|
|
217
219
|
|
|
218
|
-
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with
|
|
220
|
+
Model reports, diagnostics exports, and pull requests are very welcome. The codebase ships with 1134 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.
|
|
219
221
|
|
|
220
222
|
## Support the project
|
|
221
223
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.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": {
|
|
@@ -641,11 +641,13 @@ function parseScMapLiveState(buffer) {
|
|
|
641
641
|
* whether the point-in-polygon test genuinely rejected the position. Those
|
|
642
642
|
* three call for three different fixes.
|
|
643
643
|
*
|
|
644
|
-
* @param {{head?: {minX: number, minY: number, resolution: number
|
|
644
|
+
* @param {{head?: {minX: number, minY: number, resolution: number,
|
|
645
|
+
* sizeX?: number, sizeY?: number} | null,
|
|
645
646
|
* pose?: {x: number, y: number} | null,
|
|
646
647
|
* roomChains?: Array<{roomId: number, points: Array<{x: number, y: number}>}>} | null} liveState
|
|
647
648
|
* @returns {{roomId: number | null,
|
|
648
|
-
* reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines"
|
|
649
|
+
* reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines"
|
|
650
|
+
* | "pose-outside-outlines" | "pose-placeholder",
|
|
649
651
|
* outlineCount: number,
|
|
650
652
|
* cell: {x: number, y: number} | null,
|
|
651
653
|
* outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
|
|
@@ -677,20 +679,107 @@ function describeLiveRoomResolution(liveState) {
|
|
|
677
679
|
|
|
678
680
|
for (const chain of chains) {
|
|
679
681
|
if (pointInPolygon(cellX, cellY, chain.points)) {
|
|
682
|
+
// The cell rides along on a hit too. Without it the log could say which
|
|
683
|
+
// attempts failed and with what position, but never what a SUCCEEDING
|
|
684
|
+
// position looked like — so the two could not be compared, and the
|
|
685
|
+
// measurement below took a second field session to make.
|
|
680
686
|
return { roomId: chain.roomId, reason: "resolved", outlineCount, cell };
|
|
681
687
|
}
|
|
682
688
|
}
|
|
683
689
|
|
|
690
|
+
const outlineBounds = outlineBoundingBox(chains);
|
|
691
|
+
|
|
692
|
+
// A pose that is not merely outside the rooms but nowhere near the map is a
|
|
693
|
+
// different thing, and calling both "outside the outlines" sent every
|
|
694
|
+
// investigation down the same wrong path for three weeks.
|
|
695
|
+
//
|
|
696
|
+
// Measured on a Q7 over a 47-minute clean, 227 fetches: 226 of them placed
|
|
697
|
+
// the robot at cell 22280,22100 — the same cell every time — while the room
|
|
698
|
+
// outlines spanned 38-293 x 90-227. The underlying pose was exactly
|
|
699
|
+
// (1100, 1100) in every one, the same constant two other Q7s reported in
|
|
700
|
+
// August. The remaining fetches resolved a real room in the right order, so
|
|
701
|
+
// the robot DOES send a true pose sometimes; it just serves a placeholder in
|
|
702
|
+
// between, and those are the fetches that must not be counted as the robot
|
|
703
|
+
// being between rooms.
|
|
704
|
+
//
|
|
705
|
+
// The test is deliberately about distance rather than about the value 1100:
|
|
706
|
+
// a placeholder is a position further outside the map than the map is wide,
|
|
707
|
+
// which no real robot can be, and which stays true if Roborock picks a
|
|
708
|
+
// different constant tomorrow.
|
|
709
|
+
if (isOffTheMap(cell, head, outlineBounds)) {
|
|
710
|
+
return {
|
|
711
|
+
roomId: null,
|
|
712
|
+
reason: "pose-placeholder",
|
|
713
|
+
outlineCount,
|
|
714
|
+
cell,
|
|
715
|
+
outlineBounds,
|
|
716
|
+
head: { minX: head.minX, minY: head.minY, resolution },
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
684
720
|
return {
|
|
685
721
|
roomId: null,
|
|
686
722
|
reason: "pose-outside-outlines",
|
|
687
723
|
outlineCount,
|
|
688
724
|
cell,
|
|
689
|
-
outlineBounds
|
|
725
|
+
outlineBounds,
|
|
690
726
|
head: { minX: head.minX, minY: head.minY, resolution },
|
|
691
727
|
};
|
|
692
728
|
}
|
|
693
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Whether a cell is outside the map raster itself.
|
|
732
|
+
*
|
|
733
|
+
* The map's own `sizeX`/`sizeY` is the right yardstick and the outline
|
|
734
|
+
* bounding box is not: outlines cover rooms, while the raster covers
|
|
735
|
+
* everywhere the robot has ever been. A robot standing in a doorway, in a
|
|
736
|
+
* hallway nobody named, or against a wall the outlines do not reach is
|
|
737
|
+
* legitimately outside every outline and inside the map — that is a real miss
|
|
738
|
+
* and must keep counting as one. Being outside the raster is not a position
|
|
739
|
+
* at all; the robot cannot be somewhere it has never mapped.
|
|
740
|
+
*
|
|
741
|
+
* One raster width of slack on each side, because the transform can put a
|
|
742
|
+
* genuine edge case a little past the boundary and this must not swallow a
|
|
743
|
+
* real coordinate bug. The measured placeholder sat at cell 22280 on a map
|
|
744
|
+
* 500 cells wide — 44 times out — so the margin costs nothing.
|
|
745
|
+
*
|
|
746
|
+
* Falls back to the outline bounds when a header carries no size, which is
|
|
747
|
+
* the only case where there is nothing better to compare against.
|
|
748
|
+
*
|
|
749
|
+
* @param {{x: number, y: number}} cell
|
|
750
|
+
* @param {{minX: number, minY: number, resolution: number,
|
|
751
|
+
* sizeX?: number, sizeY?: number}} head
|
|
752
|
+
* @param {{minX: number, minY: number, maxX: number, maxY: number} | null} outlineBounds
|
|
753
|
+
* @returns {boolean}
|
|
754
|
+
*/
|
|
755
|
+
function isOffTheMap(cell, head, outlineBounds) {
|
|
756
|
+
const sizeX = Number(head?.sizeX) || 0;
|
|
757
|
+
const sizeY = Number(head?.sizeY) || 0;
|
|
758
|
+
|
|
759
|
+
if (sizeX > 0 && sizeY > 0) {
|
|
760
|
+
return (
|
|
761
|
+
cell.x < -sizeX ||
|
|
762
|
+
cell.x > sizeX * 2 ||
|
|
763
|
+
cell.y < -sizeY ||
|
|
764
|
+
cell.y > sizeY * 2
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!outlineBounds) {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const spanX = Math.max(outlineBounds.maxX - outlineBounds.minX, 1);
|
|
773
|
+
const spanY = Math.max(outlineBounds.maxY - outlineBounds.minY, 1);
|
|
774
|
+
|
|
775
|
+
return (
|
|
776
|
+
cell.x < outlineBounds.minX - spanX * 4 ||
|
|
777
|
+
cell.x > outlineBounds.maxX + spanX * 4 ||
|
|
778
|
+
cell.y < outlineBounds.minY - spanY * 4 ||
|
|
779
|
+
cell.y > outlineBounds.maxY + spanY * 4
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
694
783
|
/**
|
|
695
784
|
* Bounding box of every room outline, in the same cell space the
|
|
696
785
|
* point-in-polygon test uses.
|
|
@@ -567,17 +567,26 @@ class vacuum {
|
|
|
567
567
|
attribute
|
|
568
568
|
);
|
|
569
569
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
570
|
+
// A known attribute is skipped in silence. The line that used
|
|
571
|
+
// to be written here dated from this library's ioBroker origins,
|
|
572
|
+
// where `getObjectAsync` returns a state object that exists;
|
|
573
|
+
// under Homebridge it never exists, so the branch fired for
|
|
574
|
+
// EVERY known attribute on EVERY poll and said only that the
|
|
575
|
+
// plugin is not ioBroker. Measured on three robots with debug
|
|
576
|
+
// on: fifty lines a minute, and the log ring — the thing you
|
|
577
|
+
// need when something real goes wrong — held ninety minutes.
|
|
578
|
+
//
|
|
579
|
+
// The distinction below is the part that carries information
|
|
580
|
+
// and it is untouched: an attribute nobody has mapped yet is
|
|
581
|
+
// still reported once, by name and value.
|
|
582
|
+
if (
|
|
583
|
+
!isKnownStatusAttribute &&
|
|
575
584
|
this.rememberUnmappedStatusAttribute(duid, attribute)
|
|
576
585
|
) {
|
|
577
586
|
newlyUnmappedAttributes.push(
|
|
578
587
|
`${attribute}=${describeStatusValue(deviceStatus[0][attribute])}`
|
|
579
588
|
);
|
|
580
|
-
} else {
|
|
589
|
+
} else if (!isKnownStatusAttribute) {
|
|
581
590
|
this.adapter.log.debug(
|
|
582
591
|
`Unmapped get_status attribute ${attribute}=${describeStatusValue(deviceStatus[0][attribute])} for ${describeDevice(this.adapter, duid)}; already reported, not repeating.`
|
|
583
592
|
);
|
|
@@ -92,6 +92,12 @@ const B01_LIVE_ROOM_MISS_REASONS = {
|
|
|
92
92
|
"the map payload carried no room outlines, so there was nothing to match the position against",
|
|
93
93
|
"pose-outside-outlines":
|
|
94
94
|
"the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)",
|
|
95
|
+
// Not a miss the user can do anything about, and not the robot being
|
|
96
|
+
// between rooms: the map payload carried a placeholder where the position
|
|
97
|
+
// should be. Measured at 226 of 227 fetches on a Q7 during a 47-minute
|
|
98
|
+
// clean, always the same cell. See describeLiveRoomResolution.
|
|
99
|
+
"pose-placeholder":
|
|
100
|
+
"the map payload carried a placeholder instead of the robot's position, so this fetch could not place it (the robot sends a real position only on some fetches)",
|
|
95
101
|
};
|
|
96
102
|
|
|
97
103
|
/**
|
|
@@ -4571,6 +4577,26 @@ class Roborock {
|
|
|
4571
4577
|
// actually sees, so "no room yet" is distinguishable from "the
|
|
4572
4578
|
// feature is broken" — and say WHICH of the four causes it was,
|
|
4573
4579
|
// because they call for different fixes.
|
|
4580
|
+
// A placeholder pose is not the robot failing to be in a room, so
|
|
4581
|
+
// it does not join the count that says how long the robot has gone
|
|
4582
|
+
// unplaced. Counting it produced "after 46 unresolved position(s)"
|
|
4583
|
+
// for a robot that had been cleaning one room the whole time, which
|
|
4584
|
+
// reads as a fault and is not one.
|
|
4585
|
+
//
|
|
4586
|
+
// It is said once per run at a level the user sees, with the numbers
|
|
4587
|
+
// that make it diagnosable, and then held at debug. In the field
|
|
4588
|
+
// that turns 226 info-and-debug lines per clean into one.
|
|
4589
|
+
if (resolution2.reason === "pose-placeholder") {
|
|
4590
|
+
const placeholderMessage = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}${describeOutlineBounds(resolution2)}).`;
|
|
4591
|
+
if (!liveState.placeholderReported) {
|
|
4592
|
+
liveState.placeholderReported = true;
|
|
4593
|
+
this.log.info(placeholderMessage);
|
|
4594
|
+
} else {
|
|
4595
|
+
this.log.debug(placeholderMessage);
|
|
4596
|
+
}
|
|
4597
|
+
return liveState.current;
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4574
4600
|
liveState.unresolvedPoseCount =
|
|
4575
4601
|
(liveState.unresolvedPoseCount || 0) + 1;
|
|
4576
4602
|
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)}${describeRawMapFields(parsed)}).`;
|
|
@@ -4601,7 +4627,7 @@ class Roborock {
|
|
|
4601
4627
|
|
|
4602
4628
|
if (!previous || previous.segmentId !== roomId) {
|
|
4603
4629
|
this.log.info(
|
|
4604
|
-
`Live room for ${this.describeDevice(duid)}: ${roomName} (${roomId})${previous ? ` — was ${previous.roomName} (${previous.segmentId})` : ""}${missedBeforeThis > 0 ? ` (after ${missedBeforeThis} unresolved position(s))` : ""}.`
|
|
4630
|
+
`Live room for ${this.describeDevice(duid)}: ${roomName} (${roomId})${previous ? ` — was ${previous.roomName} (${previous.segmentId})` : ""}${missedBeforeThis > 0 ? ` (after ${missedBeforeThis} unresolved position(s))` : ""}${resolution2.cell ? ` [position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}]` : ""}.`
|
|
4605
4631
|
);
|
|
4606
4632
|
const lastV1Status = this._b01StatusState?.get(duid)?.lastV1Status;
|
|
4607
4633
|
if (this.deviceNotify && lastV1Status) {
|