homebridge-roborock-matter 2.9.0 → 2.9.2
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 +15 -0
- package/README.md +1 -1
- package/docs/issue-3958-reply.md +29 -0
- package/docs/matter-battery-issue-draft.md +57 -66
- package/package.json +1 -1
- package/roborockLib/lib/RRMapParser.js +94 -0
- package/roborockLib/lib/b01Q7Adapter.js +41 -1
- package/roborockLib/lib/deviceFeatures.js +16 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +7 -3
- package/roborockLib/roborockAPI.js +28 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.9.2
|
|
4
|
+
|
|
5
|
+
- **Max+ ("Grundig"/"Deep Clean") suction mode now announced on the S8 Pro Ultra.** Field report from a re-paired fleet: the S8 Pro Ultra only showed four suction levels because Max+ was gated to B01/Q7. The classic gate now uses the upstream-vetted per-model feature data (`set_custom_mode_max_plus` in the model's action list) — currently confirming the S8 Pro Ultra (`a70`); further models are added as feature data or field reports with diagnostics exports confirm the level. NOTE: the robot must be re-paired once for the new mode to appear (Matter locks the mode list at commissioning).
|
|
6
|
+
- Battery documentation corrected after upstream verification on homebridge#3958: `batPercentRemaining` is quality **Q (quieter)** as of Matter 1.4 (reports ARE sent via subscription, 10 s throttle) — a spec-compliant controller applies them; Apple Home in steady state does not. No plugin architecture change needed; the bridge already does the right thing.
|
|
7
|
+
|
|
8
|
+
## 2.9.1
|
|
9
|
+
|
|
10
|
+
Deep performance pass over the live-room hot paths, with honest before/after measurements.
|
|
11
|
+
|
|
12
|
+
- **Classic live-room lookup: ~23 ms + ~6.7 MB of allocations -> ~1 microsecond, zero allocations.** The RRMap was previously fully parsed every ~20 s while cleaning: parsedata materializes floor/obstacle/segment pixel arrays (hundreds of thousands of entries on a real 800x800 map) only for the tracker to look up a single pixel. The new `resolveLiveSegmentFromMapBuffer` fast path walks the block table once and reads exactly ONE pixel byte from the raw buffer (~19,000x faster, measured on an 800x800/700 KB map with a 20k-point path block). Equivalence with the full parser is locked by tests probing both paths across room, corridor and out-of-map positions.
|
|
13
|
+
- **B01 room cache is only written to disk on actual change.** The live map fetch refreshed the persisted room-name cache every ~20 s during cleaning even when nothing changed; identical room lists no longer touch the disk.
|
|
14
|
+
- **Hot debug lines no longer pay JSON.stringify when debug is off.** Template arguments are evaluated eagerly in JavaScript; the per-poll B01 status line and the per-message protocol-102 line are now gated behind the debug flag.
|
|
15
|
+
- B01 SCMap parsing consolidated to a single walk (head, pose, rooms, chains in one pass). Measured honestly: no speed win (~0.05 ms either way, the grid field is skipped via its length prefix) — kept for the simpler structure.
|
|
16
|
+
- Full suite: 259 passing (3 new fast-path equivalence/robustness tests).
|
|
17
|
+
|
|
3
18
|
## 2.9.0
|
|
4
19
|
|
|
5
20
|
- **All Apple Home feature toggles are now visible in the plugin settings UI.** The custom settings page previously exposed only a subset of the configuration; options like suction-level cleaning modes, live room tracking, room/map selection, cleaning mode selection, battery and Returning status could only be set by editing the JSON config by hand. They now live in a dedicated **Apple Home Features** section, with a clear "⚠ re-pair" marker on every option that changes the robot's announced Matter capabilities (Matter locks capabilities at commissioning — after toggling those, restart Homebridge, then remove and re-pair the robot).
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Everything is configurable from the Homebridge UI. The essentials:
|
|
|
96
96
|
|
|
97
97
|
## Battery percentage in Apple Home
|
|
98
98
|
|
|
99
|
-
Apple Home renders the battery percentage from pairing time and
|
|
99
|
+
Apple Home renders the battery percentage from pairing time and refreshes it only on a fresh read (commissioning, hub restart) — while charging state on the very same cluster updates live. This is a controller-side limitation, not a plugin bug, and it is being investigated upstream with the Homebridge team ([homebridge#3958](https://github.com/homebridge/homebridge/issues/3958)). Current state of knowledge: as of Matter 1.4 the attribute carries the **"quieter" (Q)** reporting quality — reports ARE sent over the subscription (rate-limited to one per 10 s), a Homebridge maintainer verified that a spec-compliant matter.js controller receives and applies them, yet Apple Home in steady state does not. The likely permanent fix is on Apple's side (Apple Feedback).
|
|
100
100
|
|
|
101
101
|
<details>
|
|
102
102
|
<summary>The full evidence chain and workarounds</summary>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Svar til bwp91 på homebridge#3958 — klar til at poste
|
|
2
|
+
|
|
3
|
+
```markdown
|
|
4
|
+
Thank you — this is exactly the kind of verification I was hoping for, and
|
|
5
|
+
the C → Q correction is a genuinely important update. You're right that my
|
|
6
|
+
analysis was written against the older changes-omitted reading of the
|
|
7
|
+
attribute; I've already corrected the plugin's documentation to reflect the
|
|
8
|
+
Matter 1.4 Q (quieter) quality and your subscription trace
|
|
9
|
+
(mathiashornbek/homebridge-roborock-matter@main, README + docs).
|
|
10
|
+
|
|
11
|
+
Your controller log also explains two things I had observed but couldn't
|
|
12
|
+
fully reconcile: the resync nudge's null → value transition "working" at the
|
|
13
|
+
wire level while Apple still never converged, and `batChargeState` updating
|
|
14
|
+
live throughout. Both are consistent with the reports leaving the bridge and
|
|
15
|
+
Apple simply not applying Q-quality percentage reports in steady state.
|
|
16
|
+
|
|
17
|
+
I'll run the verification you suggested on my production setup (three
|
|
18
|
+
robots: one V1, two B01) — Homebridge with matter.js debug logging through a
|
|
19
|
+
full charge cycle, watching for the subscription flushes carrying
|
|
20
|
+
`batPercentRemaining` — and post the log excerpts here. If I can get
|
|
21
|
+
chip-tool set up on the same network I'll add a subscription trace from that
|
|
22
|
+
side too.
|
|
23
|
+
|
|
24
|
+
Assuming both confirm what your trace already shows, I'll file the Apple
|
|
25
|
+
Feedback report about the controller's handling of Q-quality PowerSource
|
|
26
|
+
attributes and link it here so others can dupe it. Thanks again for digging
|
|
27
|
+
in — happy to test any builds if something changes in the Homebridge Matter
|
|
28
|
+
layer down the road.
|
|
29
|
+
```
|
|
@@ -1,66 +1,57 @@
|
|
|
1
|
-
# Matter PowerSource `batPercentRemaining`
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Matter
|
|
11
|
-
matter.js store
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
##
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Consider a device-side mitigation in the Homebridge Matter layer for bridged
|
|
60
|
-
accessories — for example bumping the cluster data version or scheduling a
|
|
61
|
-
periodic re-announce for changes-omitted attributes whose value has drifted —
|
|
62
|
-
so controllers that rely purely on subscriptions eventually converge. This
|
|
63
|
-
would fix frozen battery percentages for every bridged plugin at once, in one
|
|
64
|
-
place.
|
|
65
|
-
|
|
66
|
-
Happy to provide full logs, store dumps, and to test builds.
|
|
1
|
+
# Matter PowerSource `batPercentRemaining` — investigation record
|
|
2
|
+
|
|
3
|
+
Filed upstream as
|
|
4
|
+
[homebridge/homebridge#3958](https://github.com/homebridge/homebridge/issues/3958)
|
|
5
|
+
on 2026-07-15. This document tracks the current state of knowledge; the
|
|
6
|
+
original report text lives in the issue.
|
|
7
|
+
|
|
8
|
+
## Symptom
|
|
9
|
+
|
|
10
|
+
A Matter RVC bridged through Homebridge 2's Matter API publishes battery
|
|
11
|
+
updates continuously; the matter.js store verifiably carries the live value;
|
|
12
|
+
`batChargeState` on the same PowerSource cluster updates live in Apple Home —
|
|
13
|
+
but the rendered battery **percentage** stays at its commissioning-time value
|
|
14
|
+
until a fresh read (re-pair or Matter hub restart).
|
|
15
|
+
|
|
16
|
+
## Corrected analysis (per Homebridge maintainer verification, 2026-07-15)
|
|
17
|
+
|
|
18
|
+
The original analysis assumed the attribute carries the Matter reporting
|
|
19
|
+
quality **C (changes omitted)** — never reported via subscription, controllers
|
|
20
|
+
must poll. That was true of older spec revisions, **but as of Matter 1.4 the
|
|
21
|
+
attribute is quality Q (quieter)** , and matter.js 0.17.x (shipped with every
|
|
22
|
+
Homebridge 2.1.x release) models it accordingly:
|
|
23
|
+
|
|
24
|
+
- **Q (quieter):** reported via subscription, rate-limited to at most one
|
|
25
|
+
report per 10 seconds, plus an immediate report on any null ↔ value
|
|
26
|
+
transition.
|
|
27
|
+
|
|
28
|
+
A Homebridge maintainer (bwp91) commissioned a matter.js controller against a
|
|
29
|
+
bridge exposing `PowerSource(Battery, Rechargeable)` — the same setup
|
|
30
|
+
Homebridge builds — and logged the subscription: percentage changes propagate
|
|
31
|
+
exactly as Q prescribes (immediate first report, deferred follow-up inside the
|
|
32
|
+
10 s window, correct application after an interleaved `batChargeState` bump).
|
|
33
|
+
The "stale cluster data version" theory does not hold on the controller side.
|
|
34
|
+
|
|
35
|
+
## Where that leaves things
|
|
36
|
+
|
|
37
|
+
- The bridge **emits** the reports; a spec-compliant controller **applies**
|
|
38
|
+
them. Apple Home in steady state does not — consistent with Apple's
|
|
39
|
+
controller still treating the attribute under the older changes-omitted
|
|
40
|
+
rules and refreshing only on a fresh read.
|
|
41
|
+
- The plugin's boot-time resync nudge (null → value transition) does hit the
|
|
42
|
+
wire immediately (maintainer-confirmed) and remains useful for controllers
|
|
43
|
+
that re-prime their subscriptions; Apple still does not converge.
|
|
44
|
+
- **No device-side fix exists**: bumping the data version or re-announcing
|
|
45
|
+
only produces more of the reports Apple already receives and ignores.
|
|
46
|
+
|
|
47
|
+
## Next verification steps (requested upstream)
|
|
48
|
+
|
|
49
|
+
1. Run Homebridge with matter.js debug logging during a battery change and
|
|
50
|
+
capture the subscription flushes carrying `batPercentRemaining` — proves
|
|
51
|
+
the reports leave THIS bridge specifically.
|
|
52
|
+
2. Optionally subscribe with `chip-tool` and confirm it sees (and applies)
|
|
53
|
+
the live values.
|
|
54
|
+
|
|
55
|
+
If both confirm reports going out, the permanent fix belongs with Apple
|
|
56
|
+
(Apple Feedback report about the controller's handling of Q-quality
|
|
57
|
+
PowerSource attributes). The upstream issue stays open in the meantime.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-matter",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
4
4
|
"description": "Matter-only Homebridge plugin publishing Roborock robot vacuums (including 2025 B01/Q7-series) as native Matter accessories for Apple Home. Fork of homebridge-roborock-vacuum2.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -516,4 +516,98 @@ RRMapParser.resolveLiveSegmentId = function resolveLiveSegmentId(parsedMap) {
|
|
|
516
516
|
return null;
|
|
517
517
|
};
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Fast path for live-room tracking: resolve the robot's segment DIRECTLY
|
|
521
|
+
* from the raw RRMap buffer without materializing any pixel arrays.
|
|
522
|
+
*
|
|
523
|
+
* parsedata() collects floor/obstacle/segment pixel lists (hundreds of
|
|
524
|
+
* thousands of array entries on a real map — measured ~23 ms and ~6.7 MB of
|
|
525
|
+
* allocations per parse on an 800x800 map), which live-room tracking would
|
|
526
|
+
* pay every ~20 s only to look up a single pixel. This walk touches each
|
|
527
|
+
* block header once, remembers where the IMAGE pixel data and the robot
|
|
528
|
+
* position live, and reads exactly ONE pixel byte.
|
|
529
|
+
*
|
|
530
|
+
* The SHA1 tail check is intentionally skipped here: the transport layer
|
|
531
|
+
* already gunzips the payload (gzip's CRC32 validates integrity), and the
|
|
532
|
+
* header magic + block-structure walk reject malformed buffers.
|
|
533
|
+
*
|
|
534
|
+
* @param {Buffer} buf raw RRMap buffer (decrypted and gunzipped)
|
|
535
|
+
* @returns {number | null} segment id, or null when position/geometry is
|
|
536
|
+
* missing, the robot is outside the image, or on an unsegmented pixel.
|
|
537
|
+
*/
|
|
538
|
+
RRMapParser.resolveLiveSegmentFromMapBuffer = function (buf) {
|
|
539
|
+
if (
|
|
540
|
+
!Buffer.isBuffer(buf) ||
|
|
541
|
+
buf.length < 0x18 ||
|
|
542
|
+
buf[0x00] !== 0x72 ||
|
|
543
|
+
buf[0x01] !== 0x72
|
|
544
|
+
) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const dataLength = buf.readUInt32LE(OFFSETS.LENGTH);
|
|
549
|
+
const end = Math.min(dataLength, buf.length);
|
|
550
|
+
|
|
551
|
+
let image = null;
|
|
552
|
+
let position = null;
|
|
553
|
+
|
|
554
|
+
let dataPosition = 0x14;
|
|
555
|
+
while (dataPosition + 8 <= end) {
|
|
556
|
+
const type = buf.readUInt16LE(dataPosition);
|
|
557
|
+
const hlength = buf.readUInt16LE(dataPosition + OFFSETS.HLENGTH);
|
|
558
|
+
const length = buf.readUInt32LE(dataPosition + OFFSETS.LENGTH);
|
|
559
|
+
if (hlength < 8 || dataPosition + hlength + length > buf.length) {
|
|
560
|
+
return null; // malformed block table
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (type === TYPES.IMAGE && hlength > 24) {
|
|
564
|
+
const offset1 = buf.readUInt8(dataPosition + 2); // == hlength low byte
|
|
565
|
+
image = {
|
|
566
|
+
pixelsAt: dataPosition + offset1,
|
|
567
|
+
top: buf.readInt32LE(dataPosition + offset1 - 0x10),
|
|
568
|
+
left: buf.readInt32LE(dataPosition + offset1 - 0x0c),
|
|
569
|
+
height: buf.readInt32LE(dataPosition + offset1 - 0x08),
|
|
570
|
+
width: buf.readInt32LE(dataPosition + offset1 - 0x04),
|
|
571
|
+
length,
|
|
572
|
+
};
|
|
573
|
+
} else if (type === TYPES.ROBOT_POSITION) {
|
|
574
|
+
const offset1 = buf.readUInt8(dataPosition + 2);
|
|
575
|
+
position = [
|
|
576
|
+
buf.readInt32LE(dataPosition + offset1),
|
|
577
|
+
buf.readInt32LE(dataPosition + (length >= 12 ? offset1 + 4 : offset1)),
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
dataPosition += hlength + length;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!image || !position || image.width <= 0 || image.height <= 0) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const pixelX = Math.floor(position[0] / 50) - image.left;
|
|
589
|
+
const pixelY = Math.floor(position[1] / 50) - image.top;
|
|
590
|
+
if (
|
|
591
|
+
pixelX < 0 ||
|
|
592
|
+
pixelY < 0 ||
|
|
593
|
+
pixelX >= image.width ||
|
|
594
|
+
pixelY >= image.height
|
|
595
|
+
) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const pixelIndex = pixelY * image.width + pixelX;
|
|
600
|
+
if (pixelIndex >= image.length) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const pixelByte = buf.readUInt8(image.pixelsAt + pixelIndex);
|
|
605
|
+
const pixelType = pixelByte & 0x07;
|
|
606
|
+
if (pixelType === 0 || pixelType === 1) {
|
|
607
|
+
return null; // outside the map or an obstacle pixel
|
|
608
|
+
}
|
|
609
|
+
const segmentId = (pixelByte & 248) >> 3;
|
|
610
|
+
return segmentId !== 0 ? segmentId : null;
|
|
611
|
+
};
|
|
612
|
+
|
|
519
613
|
module.exports = RRMapParser;
|
|
@@ -381,6 +381,41 @@ function parseScMapLiveState(buffer) {
|
|
|
381
381
|
return chain;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/** @param {Buffer} buf */
|
|
385
|
+
function parseRoomEntry(buf) {
|
|
386
|
+
const room = { roomId: -1, roomName: "" };
|
|
387
|
+
let pos = 0;
|
|
388
|
+
while (pos < buf.length) {
|
|
389
|
+
const tag = readVarint(buf, pos);
|
|
390
|
+
pos = tag.pos;
|
|
391
|
+
const fieldNumber = Math.floor(tag.value / 8);
|
|
392
|
+
const wireType = tag.value % 8;
|
|
393
|
+
if (fieldNumber === 1 && wireType === 0) {
|
|
394
|
+
const value = readVarint(buf, pos);
|
|
395
|
+
room.roomId = value.value;
|
|
396
|
+
pos = value.pos;
|
|
397
|
+
} else if (fieldNumber === 2 && wireType === 2) {
|
|
398
|
+
const len = readVarint(buf, pos);
|
|
399
|
+
room.roomName = buf
|
|
400
|
+
.subarray(len.pos, len.pos + len.value)
|
|
401
|
+
.toString("utf8");
|
|
402
|
+
pos = len.pos + len.value;
|
|
403
|
+
} else {
|
|
404
|
+
pos = skipField(buf, pos, wireType);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return room;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @type {Array<{roomId: number, roomName: string}>} */
|
|
411
|
+
const rooms = [];
|
|
412
|
+
|
|
413
|
+
// Single pass over the RobotMap wire format: head, pose, rooms and room
|
|
414
|
+
// chains are all collected in one walk instead of delegating rooms to a
|
|
415
|
+
// second parseRoomsFromScMap scan. Measured honestly: the win is
|
|
416
|
+
// negligible (~0.05 ms either way — skipField jumps the large grid field
|
|
417
|
+
// via its length prefix without touching bytes); this is kept for the
|
|
418
|
+
// simpler single-walk structure, not for speed.
|
|
384
419
|
let pos = 0;
|
|
385
420
|
while (pos < buffer.length) {
|
|
386
421
|
const tag = readVarint(buffer, pos);
|
|
@@ -395,6 +430,11 @@ function parseScMapLiveState(buffer) {
|
|
|
395
430
|
head = parseMapHead(body);
|
|
396
431
|
} else if (fieldNumber === 8) {
|
|
397
432
|
pose = parseCurrentPose(body);
|
|
433
|
+
} else if (fieldNumber === 12) {
|
|
434
|
+
const room = parseRoomEntry(body);
|
|
435
|
+
if (room.roomId >= 0) {
|
|
436
|
+
rooms.push(room);
|
|
437
|
+
}
|
|
398
438
|
} else if (fieldNumber === 14) {
|
|
399
439
|
const chain = parseRoomChain(body);
|
|
400
440
|
if (chain.roomId >= 0 && chain.points.length >= 3) {
|
|
@@ -410,7 +450,7 @@ function parseScMapLiveState(buffer) {
|
|
|
410
450
|
return {
|
|
411
451
|
head,
|
|
412
452
|
pose,
|
|
413
|
-
rooms
|
|
453
|
+
rooms,
|
|
414
454
|
roomChains:
|
|
415
455
|
/** @type {Array<{roomId: number, points: Array<{x: number, y: number}>}>} */ (
|
|
416
456
|
roomChains
|
|
@@ -1345,7 +1345,23 @@ class deviceFeatures {
|
|
|
1345
1345
|
}
|
|
1346
1346
|
}
|
|
1347
1347
|
|
|
1348
|
+
// Classic v1 models whose upstream-vetted per-model action list includes
|
|
1349
|
+
// `set_custom_mode_max_plus` — the verified signal that the robot's firmware
|
|
1350
|
+
// accepts the Max+ suction level (v1 fan power 108). Keep this in sync with
|
|
1351
|
+
// the modelConfig action lists in processSupportedFeatures above; models are
|
|
1352
|
+
// only added here once the upstream feature data (or a field report with a
|
|
1353
|
+
// diagnostics export) confirms the level.
|
|
1354
|
+
const MAX_PLUS_FAN_POWER_MODELS = new Set([
|
|
1355
|
+
"roborock.vacuum.a70", // S8 Pro Ultra
|
|
1356
|
+
]);
|
|
1357
|
+
|
|
1358
|
+
/** @param {string} model */
|
|
1359
|
+
function supportsMaxPlusFanPower(model) {
|
|
1360
|
+
return MAX_PLUS_FAN_POWER_MODELS.has(model);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1348
1363
|
module.exports = {
|
|
1349
1364
|
deviceFeatures,
|
|
1350
1365
|
stateCodes,
|
|
1366
|
+
supportsMaxPlusFanPower,
|
|
1351
1367
|
};
|
|
@@ -277,9 +277,13 @@ class roborock_mqtt_connector {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
if (dps.id !== undefined) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
)
|
|
280
|
+
// Runs for every cloud message; only pay the stringify cost
|
|
281
|
+
// when debug logging is actually enabled.
|
|
282
|
+
if (this.adapter.config.debug) {
|
|
283
|
+
this.adapter.log.debug(
|
|
284
|
+
`Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
283
287
|
if (typeof dps.result !== "undefined") {
|
|
284
288
|
this.adapter.setStateAsync("CloudMessage", {
|
|
285
289
|
duid,
|
|
@@ -14,6 +14,8 @@ const roborock_mqtt_connector =
|
|
|
14
14
|
const rrMessage = require("./lib/message").message;
|
|
15
15
|
const vacuum_class = require("./lib/vacuum").vacuum;
|
|
16
16
|
const deviceFeatures = require("./lib/deviceFeatures").deviceFeatures;
|
|
17
|
+
const supportsMaxPlusFanPower =
|
|
18
|
+
require("./lib/deviceFeatures").supportsMaxPlusFanPower;
|
|
17
19
|
const RRMapParser = require("./lib/RRMapParser");
|
|
18
20
|
const messageQueueHandler =
|
|
19
21
|
require("./lib/messageQueueHandler").messageQueueHandler;
|
|
@@ -1944,6 +1946,12 @@ class Roborock {
|
|
|
1944
1946
|
canVacuum: true,
|
|
1945
1947
|
canMop: hasWaterModeSchema || hasMopSchema || hasMopFeature,
|
|
1946
1948
|
canControlFanPower,
|
|
1949
|
+
// Max+ (fan power 108) only where the upstream-vetted per-model
|
|
1950
|
+
// feature data confirms the level (e.g. S8 Pro Ultra) — field
|
|
1951
|
+
// reports with diagnostics exports extend the list.
|
|
1952
|
+
canMaxPlusFanPower: supportsMaxPlusFanPower(
|
|
1953
|
+
this.getProductAttribute(duid, "model")
|
|
1954
|
+
),
|
|
1947
1955
|
canControlWater:
|
|
1948
1956
|
hasWaterModeSchema ||
|
|
1949
1957
|
this.hasVacuumFeature(duid, [
|
|
@@ -3467,9 +3475,13 @@ class Roborock {
|
|
|
3467
3475
|
);
|
|
3468
3476
|
}
|
|
3469
3477
|
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
)
|
|
3478
|
+
// Template args are evaluated even when debug logging is off, and
|
|
3479
|
+
// this line runs on every successful poll — gate the stringify.
|
|
3480
|
+
if (this.config.debug) {
|
|
3481
|
+
this.log.debug(
|
|
3482
|
+
`B01 status for ${duid}: ${JSON.stringify(data)} -> ${JSON.stringify(v1Status)}`
|
|
3483
|
+
);
|
|
3484
|
+
}
|
|
3473
3485
|
|
|
3474
3486
|
if (this.deviceNotify !== undefined) {
|
|
3475
3487
|
this.deviceNotify("CloudMessage", { duid, payload: [v1Status] });
|
|
@@ -3752,13 +3764,18 @@ class Roborock {
|
|
|
3752
3764
|
|
|
3753
3765
|
// The live fetch already paid for the full map payload; reuse it to
|
|
3754
3766
|
// keep the room-name cache fresh instead of scheduling another
|
|
3755
|
-
// 6-hour refreshB01Rooms fetch of the same data.
|
|
3767
|
+
// 6-hour refreshB01Rooms fetch of the same data. Written only on
|
|
3768
|
+
// actual change — the cache is persisted to disk, and rewriting an
|
|
3769
|
+
// identical room list every ~20 s during cleaning is pure I/O waste.
|
|
3756
3770
|
if (parsed.rooms.length > 0) {
|
|
3757
3771
|
if (!this._b01RoomRefreshAt) {
|
|
3758
3772
|
this._b01RoomRefreshAt = new Map();
|
|
3759
3773
|
}
|
|
3760
3774
|
this._b01RoomRefreshAt.set(duid, Date.now());
|
|
3761
|
-
|
|
3775
|
+
const cachedRooms = JSON.stringify(this.getB01RoomCache(duid));
|
|
3776
|
+
if (cachedRooms !== JSON.stringify(parsed.rooms)) {
|
|
3777
|
+
await this.setB01RoomCache(duid, parsed.rooms);
|
|
3778
|
+
}
|
|
3762
3779
|
}
|
|
3763
3780
|
|
|
3764
3781
|
const roomId = b01Q7Adapter.resolveLiveRoomId(parsed);
|
|
@@ -3944,9 +3961,12 @@ class Roborock {
|
|
|
3944
3961
|
return liveState.current;
|
|
3945
3962
|
}
|
|
3946
3963
|
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3964
|
+
// Fast path: reads the single pixel under the robot directly from
|
|
3965
|
+
// the raw buffer — no pixel arrays are materialized (parsedata costs
|
|
3966
|
+
// ~23 ms + ~6.7 MB of allocations on a real-size map; this is
|
|
3967
|
+
// microseconds).
|
|
3968
|
+
const segmentId =
|
|
3969
|
+
RRMapParser.resolveLiveSegmentFromMapBuffer(mapBuffer);
|
|
3950
3970
|
liveState.consecutiveFailures = 0;
|
|
3951
3971
|
|
|
3952
3972
|
if (segmentId === null) {
|