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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -3467,9 +3467,13 @@ class Roborock {
|
|
|
3467
3467
|
);
|
|
3468
3468
|
}
|
|
3469
3469
|
|
|
3470
|
-
|
|
3471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
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) {
|