homebridge-roborock-matter 2.8.1 → 2.9.1

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.1
4
+
5
+ Deep performance pass over the live-room hot paths, with honest before/after measurements.
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - 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.
11
+ - Full suite: 259 passing (3 new fast-path equivalence/robustness tests).
12
+
13
+ ## 2.9.0
14
+
15
+ - **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).
16
+ - No functional changes to the plugin itself.
17
+
3
18
  ## 2.8.1
4
19
 
5
20
  - **Suction modes now render with proper localized names in Apple Home.** Field observation: Apple ignores Matter mode labels and renders its own localized names from the mode TAGS (a variant with only the Vacuum tag displays as plain "Vacuum"). Balanced and Turbo therefore now carry distinct intensity tags (Auto and Quick), matching Quiet and Max — in Apple Home the five levels render as Quiet / Automatic / Quick / Max (+ Deep Clean for Max+ on Q7). Remember: enabling `enableFanPowerCleanModes` requires one remove/re-pair of the robot, since Matter fixes the mode list at commissioning.
package/README.md CHANGED
@@ -66,6 +66,12 @@ While a robot is actively cleaning, the plugin fetches its live position from th
66
66
 
67
67
  Enabled by default; opt out with `enableLiveRoomTracking: false`.
68
68
 
69
+ ## Suction modes (optional)
70
+
71
+ Enable **Enable Suction-Level Cleaning Modes** (`enableFanPowerCleanModes`) and Apple Home's mode picker gains the suction levels — rendered by Apple with localized names from the Matter mode tags: **Quiet / Automatic / Quick / Max** (+ **Deep Clean** for the Q7's Max+ level). The current mode follows the robot live, so suction changed in the Roborock app shows up in Apple Home too.
72
+
73
+ > ⚠️ **Re-pairing required:** Matter locks an accessory's mode list at commissioning. After enabling (or disabling) this option, restart Homebridge, then **remove the robot from Apple Home and pair it again** — otherwise the new modes will not appear. The same applies to any option that changes announced capabilities.
74
+
69
75
  ## Supported robots
70
76
 
71
77
  - **B01/Q7-series (2025):** `roborock.vacuum.sc05` and compatible (Q7 M5 / M5+), including manual-tank mopping with vacuum/mop mode switching.
@@ -82,39 +82,6 @@
82
82
  </label>
83
83
  </div>
84
84
 
85
- <label class="checkbox">
86
- <input id="enable-matter-charging-docked" type="checkbox" />
87
- <span>
88
- Show Charging/Docked on the Apple Home tile
89
- <small
90
- >Reports real Matter Charging and Docked states instead of Ready while
91
- on the dock. Requires a recent iOS/tvOS; turn off and restart the
92
- bridge if the tile misbehaves.</small
93
- >
94
- </span>
95
- </label>
96
- <label class="field">
97
- <span>Charged Battery Threshold (%)</span>
98
- <input
99
- id="matter-charged-battery-threshold"
100
- type="number"
101
- min="1"
102
- max="100"
103
- step="1"
104
- placeholder="100"
105
- />
106
- <small
107
- >The tile shows Charging while docked below this battery percentage, and
108
- Docked at or above it. Lower it (e.g. 90) if a worn battery makes the
109
- robot report fully charged early.</small
110
- >
111
- </label>
112
- <p class="help matter-setup-note">
113
- After saving and restarting the Roborock bridge, use the Matter Pairing
114
- section below. Apple Home may need both the bridge QR code and the
115
- vacuum's 11-digit setup code.
116
- </p>
117
-
118
85
  <details id="advanced-settings" class="advanced-settings">
119
86
  <summary>Advanced troubleshooting</summary>
120
87
  <p class="help">
@@ -164,6 +131,130 @@
164
131
  </div>
165
132
  </section>
166
133
 
134
+ <section class="panel">
135
+ <div class="section-header">
136
+ <div>
137
+ <h2>Apple Home Features</h2>
138
+ <p class="help">
139
+ What each robot exposes through Matter. Options marked
140
+ <strong>&#9888; re-pair</strong> change the robot's announced
141
+ capabilities: after toggling them, save, restart Homebridge, then
142
+ remove the robot from Apple Home and pair it again — Matter locks
143
+ capabilities at pairing time.
144
+ </p>
145
+ </div>
146
+ </div>
147
+
148
+ <label class="checkbox">
149
+ <input id="enable-matter-service-area" type="checkbox" />
150
+ <span>
151
+ Room &amp; map selection <strong>&#9888; re-pair</strong>
152
+ <small
153
+ >Pick rooms to clean directly in Apple Home, using your Roborock room
154
+ names (multi-map homes included).</small
155
+ >
156
+ </span>
157
+ </label>
158
+
159
+ <label class="checkbox">
160
+ <input id="enable-live-room-tracking" type="checkbox" />
161
+ <span>
162
+ Live room tracking
163
+ <small
164
+ >While cleaning, the robot's map position is resolved to the room it
165
+ is physically inside and shown as the current room in Apple Home
166
+ (fetched every ~20 s during active runs only).</small
167
+ >
168
+ </span>
169
+ </label>
170
+
171
+ <label class="checkbox">
172
+ <input id="enable-matter-clean-mode" type="checkbox" />
173
+ <span>
174
+ Cleaning mode selection <strong>&#9888; re-pair</strong>
175
+ <small
176
+ >Vacuum / Mop / Vacuum + Mop in Apple Home's mode picker,
177
+ capability-gated per robot.</small
178
+ >
179
+ </span>
180
+ </label>
181
+
182
+ <label class="checkbox">
183
+ <input id="enable-fan-power-clean-modes" type="checkbox" />
184
+ <span>
185
+ Suction-level cleaning modes <strong>&#9888; re-pair</strong>
186
+ <small
187
+ >Adds Quiet / Balanced / Turbo / Max (and Max+ on Q7) to the mode
188
+ picker — Apple renders them as Quiet / Automatic / Quick / Max / Deep
189
+ Clean. Requires cleaning mode selection above.</small
190
+ >
191
+ </span>
192
+ </label>
193
+
194
+ <label class="checkbox">
195
+ <input id="enable-matter-power-source" type="checkbox" />
196
+ <span>
197
+ Battery status <strong>&#9888; re-pair</strong>
198
+ <small
199
+ >Battery percentage and charging state on the Matter accessory. See
200
+ the README for a known Apple-side limitation on percentage
201
+ refresh.</small
202
+ >
203
+ </span>
204
+ </label>
205
+
206
+ <label class="checkbox">
207
+ <input id="enable-matter-extended-states" type="checkbox" />
208
+ <span>
209
+ Returning status
210
+ <small
211
+ >Shows the extended Matter Returning state while the robot heads back
212
+ to the dock. Off by default for maximum Apple Home
213
+ compatibility.</small
214
+ >
215
+ </span>
216
+ </label>
217
+
218
+ <label class="checkbox">
219
+ <input id="enable-matter-charging-docked" type="checkbox" />
220
+ <span>
221
+ Show Charging/Docked on the Apple Home tile
222
+ <small
223
+ >Reports real Matter Charging and Docked states instead of Ready while
224
+ on the dock. Requires a recent iOS/tvOS; turn off and restart the
225
+ bridge if the tile misbehaves.</small
226
+ >
227
+ </span>
228
+ </label>
229
+ <label class="field">
230
+ <span>Charged Battery Threshold (%)</span>
231
+ <input
232
+ id="matter-charged-battery-threshold"
233
+ type="number"
234
+ min="1"
235
+ max="100"
236
+ step="1"
237
+ placeholder="100"
238
+ />
239
+ <small
240
+ >The tile shows Charging while docked below this battery percentage, and
241
+ Docked at or above it. Lower it (e.g. 90) if a worn battery makes the
242
+ robot report fully charged early.</small
243
+ >
244
+ </label>
245
+ <p class="help matter-setup-note">
246
+ After saving and restarting the Roborock bridge, use the Matter Pairing
247
+ section below. Apple Home may need both the bridge QR code and the
248
+ vacuum's 11-digit setup code.
249
+ </p>
250
+
251
+ <div class="actions">
252
+ <button id="save-feature-settings" class="secondary">
253
+ Save Settings
254
+ </button>
255
+ </div>
256
+ </section>
257
+
167
258
  <section class="panel">
168
259
  <div class="section-header">
169
260
  <div>
@@ -8,6 +8,21 @@ const elements = {
8
8
  devicesEmpty: document.getElementById("devices-empty"),
9
9
  refreshDevices: document.getElementById("refresh-devices"),
10
10
  debugMode: document.getElementById("debug-mode"),
11
+ enableMatterServiceArea: document.getElementById(
12
+ "enable-matter-service-area"
13
+ ),
14
+ enableLiveRoomTracking: document.getElementById("enable-live-room-tracking"),
15
+ enableMatterCleanMode: document.getElementById("enable-matter-clean-mode"),
16
+ enableFanPowerCleanModes: document.getElementById(
17
+ "enable-fan-power-clean-modes"
18
+ ),
19
+ enableMatterPowerSource: document.getElementById(
20
+ "enable-matter-power-source"
21
+ ),
22
+ enableMatterExtendedStates: document.getElementById(
23
+ "enable-matter-extended-states"
24
+ ),
25
+ saveFeatureSettings: document.getElementById("save-feature-settings"),
11
26
  enableMatterChargingDocked: document.getElementById(
12
27
  "enable-matter-charging-docked"
13
28
  ),
@@ -124,6 +139,35 @@ async function loadConfig() {
124
139
  elements.skipDevices.value = config.skipDevices;
125
140
  }
126
141
  elements.debugMode.checked = Boolean(config.debugMode);
142
+ // Apple Home feature toggles. Service Area, clean mode, power source and
143
+ // live room tracking default ON (config semantics: `!== false`);
144
+ // suction-level modes and extended states default OFF.
145
+ if (elements.enableMatterServiceArea) {
146
+ elements.enableMatterServiceArea.checked =
147
+ config.enableMatterServiceArea !== false;
148
+ }
149
+ if (elements.enableLiveRoomTracking) {
150
+ elements.enableLiveRoomTracking.checked =
151
+ config.enableLiveRoomTracking !== false;
152
+ }
153
+ if (elements.enableMatterCleanMode) {
154
+ elements.enableMatterCleanMode.checked =
155
+ config.enableMatterCleanMode !== false;
156
+ }
157
+ if (elements.enableFanPowerCleanModes) {
158
+ elements.enableFanPowerCleanModes.checked = Boolean(
159
+ config.enableFanPowerCleanModes
160
+ );
161
+ }
162
+ if (elements.enableMatterPowerSource) {
163
+ elements.enableMatterPowerSource.checked =
164
+ config.enableMatterPowerSource !== false;
165
+ }
166
+ if (elements.enableMatterExtendedStates) {
167
+ elements.enableMatterExtendedStates.checked = Boolean(
168
+ config.enableMatterExtendedOperationalStates
169
+ );
170
+ }
127
171
  if (elements.enableMatterChargingDocked) {
128
172
  elements.enableMatterChargingDocked.checked = Boolean(
129
173
  config.enableMatterChargingDockedStates
@@ -213,6 +257,16 @@ function getFormValues() {
213
257
  baseURL: getBaseUrl(),
214
258
  skipDevices: getSkipDevices(),
215
259
  debugMode: getDebugMode(),
260
+ enableMatterServiceArea: Boolean(elements.enableMatterServiceArea?.checked),
261
+ enableLiveRoomTracking: Boolean(elements.enableLiveRoomTracking?.checked),
262
+ enableMatterCleanMode: Boolean(elements.enableMatterCleanMode?.checked),
263
+ enableFanPowerCleanModes: Boolean(
264
+ elements.enableFanPowerCleanModes?.checked
265
+ ),
266
+ enableMatterPowerSource: Boolean(elements.enableMatterPowerSource?.checked),
267
+ enableMatterExtendedOperationalStates: Boolean(
268
+ elements.enableMatterExtendedStates?.checked
269
+ ),
216
270
  enableMatterChargingDockedStates: Boolean(
217
271
  elements.enableMatterChargingDocked?.checked
218
272
  ),
@@ -1207,6 +1261,11 @@ function init() {
1207
1261
  showToast("error", "Failed to load current config.");
1208
1262
  });
1209
1263
  elements.saveSettings.addEventListener("click", () => saveCredentials(true));
1264
+ if (elements.saveFeatureSettings) {
1265
+ elements.saveFeatureSettings.addEventListener("click", () =>
1266
+ saveCredentials(true)
1267
+ );
1268
+ }
1210
1269
  elements.login.addEventListener("click", login);
1211
1270
  elements.send2fa.addEventListener("click", sendTwoFactorEmail);
1212
1271
  elements.verify2fa.addEventListener("click", verifyTwoFactorCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
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
@@ -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,
@@ -3467,9 +3467,13 @@ class Roborock {
3467
3467
  );
3468
3468
  }
3469
3469
 
3470
- this.log.debug(
3471
- `B01 status for ${duid}: ${JSON.stringify(data)} -> ${JSON.stringify(v1Status)}`
3472
- );
3470
+ // Template args are evaluated even when debug logging is off, and
3471
+ // this line runs on every successful poll — gate the stringify.
3472
+ if (this.config.debug) {
3473
+ this.log.debug(
3474
+ `B01 status for ${duid}: ${JSON.stringify(data)} -> ${JSON.stringify(v1Status)}`
3475
+ );
3476
+ }
3473
3477
 
3474
3478
  if (this.deviceNotify !== undefined) {
3475
3479
  this.deviceNotify("CloudMessage", { duid, payload: [v1Status] });
@@ -3752,13 +3756,18 @@ class Roborock {
3752
3756
 
3753
3757
  // The live fetch already paid for the full map payload; reuse it to
3754
3758
  // keep the room-name cache fresh instead of scheduling another
3755
- // 6-hour refreshB01Rooms fetch of the same data.
3759
+ // 6-hour refreshB01Rooms fetch of the same data. Written only on
3760
+ // actual change — the cache is persisted to disk, and rewriting an
3761
+ // identical room list every ~20 s during cleaning is pure I/O waste.
3756
3762
  if (parsed.rooms.length > 0) {
3757
3763
  if (!this._b01RoomRefreshAt) {
3758
3764
  this._b01RoomRefreshAt = new Map();
3759
3765
  }
3760
3766
  this._b01RoomRefreshAt.set(duid, Date.now());
3761
- await this.setB01RoomCache(duid, parsed.rooms);
3767
+ const cachedRooms = JSON.stringify(this.getB01RoomCache(duid));
3768
+ if (cachedRooms !== JSON.stringify(parsed.rooms)) {
3769
+ await this.setB01RoomCache(duid, parsed.rooms);
3770
+ }
3762
3771
  }
3763
3772
 
3764
3773
  const roomId = b01Q7Adapter.resolveLiveRoomId(parsed);
@@ -3944,9 +3953,12 @@ class Roborock {
3944
3953
  return liveState.current;
3945
3954
  }
3946
3955
 
3947
- const parser = this.vacuums[duid]?.mapParser || new RRMapParser(this);
3948
- const parsedMap = await parser.parsedata(mapBuffer);
3949
- const segmentId = RRMapParser.resolveLiveSegmentId(parsedMap);
3956
+ // Fast path: reads the single pixel under the robot directly from
3957
+ // the raw buffer — no pixel arrays are materialized (parsedata costs
3958
+ // ~23 ms + ~6.7 MB of allocations on a real-size map; this is
3959
+ // microseconds).
3960
+ const segmentId =
3961
+ RRMapParser.resolveLiveSegmentFromMapBuffer(mapBuffer);
3950
3962
  liveState.consecutiveFailures = 0;
3951
3963
 
3952
3964
  if (segmentId === null) {