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 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 never re-reads it — the Matter attribute carries the spec's "changes omitted" reporting quality, so value changes are not pushed to controllers _by design_ (charging state on the very same cluster updates live). This is a controller-side limitation verified end-to-end, not a plugin bug.
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` never refreshes in controllers (changes-omitted attribute; device-side store is fresh)
2
-
3
- Ready to file against `homebridge/homebridge` (Matter API). Title suggestion:
4
-
5
- > Matter bridged accessories: `batPercentRemaining` freezes at pairing-time value in Apple Home (changes-omitted attribute; store verifiably fresh)
6
-
7
- ## Summary
8
-
9
- A Matter Robotic Vacuum Cleaner (device type 0x74) exposed through Homebridge 2's
10
- Matter API publishes battery updates continuously via `updateAccessoryState`. The
11
- matter.js store demonstrably carries the live value. Controllers (Apple Home,
12
- multi-hub) keep rendering the value from pairing time indefinitely across
13
- plugin restarts, Homebridge restarts, and days of uptime while
14
- `batChargeState` on the very same cluster updates live.
15
-
16
- ## Environment
17
-
18
- - Homebridge 2.1.x (Matter API), plugin `homebridge-roborock-matter` publishing
19
- full-state snapshots via `updateAccessoryState`
20
- - Three robots (one V1-protocol, two B01-protocol) identical behavior on all
21
- - Controller: Apple Home (Apple TV + HomePod hubs, current iOS/tvOS)
22
-
23
- ## Reproduction
24
-
25
- 1. Pair a bridged Matter accessory that exposes PowerSource with a battery
26
- percentage that changes over time (any RVC works).
27
- 2. Let the battery drain or charge by 10+ points.
28
- 3. Compare the Apple Home tile with the matter.js store on disk.
29
-
30
- ## Evidence chain
31
-
32
- 1. Plugin log shows continuous publishes with live values (73% → 92% over an
33
- hour, all three robots).
34
- 2. `grep -ro "batPercentRemaining[^,}]*" <storage> --include="*.json"` shows the
35
- persisted matter.js store matching the vendor app in real time (e.g. 170 =
36
- 85% while Apple renders 74%).
37
- 3. `batChargeState` (same PowerSource cluster) propagates to Apple Home in
38
- near-real-time throughout.
39
- 4. The values Apple renders are exactly each robot's value from its
40
- commissioning moment; a re-pair refreshes once, then freezes again.
41
- 5. A device-side "resync nudge" (publishing the attribute as briefly unknown,
42
- then real, to force two genuine store changes and a data-version bump) helps
43
- only controllers that re-prime their subscription (e.g. after a hub
44
- restart); Apple in steady state still never converges.
45
-
46
- ## Analysis
47
-
48
- `batPercentRemaining` (and `batTimeToFullCharge`) carry the Matter spec
49
- reporting quality **"changes omitted" (C)**. matter.js honors this on the
50
- device side (changes are not delivered via subscription reports), and the
51
- matter.js controller compensates on its own side ("Always read attributes that
52
- do not report changes via subscriptions" matter.js changelog 0.10). Apple's
53
- controller evidently performs no such re-reads, so bridged accessories freeze
54
- on the pairing-time percentage. No plugin-side write can force a
55
- changes-omitted attribute to report.
56
-
57
- ## Suggestion
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.0",
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: parseRoomsFromScMap(buffer),
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
- this.adapter.log.debug(
281
- `Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
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
- this.log.debug(
3471
- `B01 status for ${duid}: ${JSON.stringify(data)} -> ${JSON.stringify(v1Status)}`
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
- await this.setB01RoomCache(duid, parsed.rooms);
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
- const parser = this.vacuums[duid]?.mapParser || new RRMapParser(this);
3948
- const parsedMap = await parser.parsedata(mapBuffer);
3949
- const segmentId = RRMapParser.resolveLiveSegmentId(parsedMap);
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) {