homebridge-roborock-matter 2.9.0 → 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,15 @@
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
+
3
13
  ## 2.9.0
4
14
 
5
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).
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.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) {