homebridge-roborock-matter 3.4.8 → 3.4.10

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.10
4
+
5
+ **The Q7 position that never resolved to a room is not a position at all.** 3.4.9 asked the two Q7s to report the range their room outlines occupy, and they answered: Garage sat in a map spanning cells 52–171 by 43–187, 1. Sal in one spanning 38–293 by 90–227. Back-computing through each map's own origin and resolution gives the same coordinate for both — exactly (1100.0, 1100.0), on two robots, two maps, and twelve minutes of active cleaning. A number that identical is arithmetic, not a place a robot stood, which means live-room tracking on these models has never worked from that field.
6
+
7
+ - **The miss line now surveys the payload rather than asserting anything about it.** It prints the size of every top-level field and every scalar inside the small ones, keyed by field path. Two consecutive lines are then a diff: the value that changed while the robot was driving is the position, and the submessage that grew is the trail behind it.
8
+ - **Varints are surveyed, not just floats.** The pose message carries an `update` flag alongside its coordinates, so a float-only dump would have printed two plausible-looking numbers and hidden the field saying they were stale.
9
+ - **The survey descends one level.** A pose trail's last point is by construction where the robot is now, and repeated paths overwrite, so the end of a trail lands in the log under a stable key.
10
+ - **A bare scalar on the map itself is now visible.** The parse loop only ever descended into submessages, so a position stored as a plain float would not have appeared anywhere.
11
+ - **It is bounded and it cannot throw.** The occupancy grid is measured rather than walked, the scalar count is capped, recursion is capped, and bytes that turn out not to be protobuf are swallowed. A diagnostic must never be the reason a robot stops reporting its room.
12
+
13
+ This changes no behaviour. It exists because guessing another field number would have been the third guess in a row on this code path, and the robots were running.
14
+
15
+ ## 3.4.9
16
+
17
+ - **A live-room miss now says where the rooms actually are.** Two Q7s produced position cells around 22,000 while a Roborock map is a couple of thousand cells across at most — so those robots were never "between rooms", their computed position was nowhere near the map. One of them reported x exactly equal to y, which is arithmetic rather than a place a robot stood. The position on its own cannot separate a unit mismatch from a wrong origin, so the miss line now carries the range the room outlines occupy plus the map origin and resolution the transform used. This changes no behaviour; it turns the next log from a hypothesis into a measurement. The bounding box is computed only on the failure path, so a run that resolves every position pays nothing.
18
+
3
19
  ## 3.4.8
4
20
 
5
21
  **Selecting "Vacuum" and getting a vacuum-and-mop was not a display bug — one timed-out command cancelled the one that mattered.** skmzwanke reported in [#8](https://github.com/mathiashornbek/homebridge-roborock-matter/issues/8) that he selected Vacuum for a single room and the robot mopped it anyway, and his 3.4.5 log names the cause outright.
package/README.md CHANGED
@@ -37,7 +37,7 @@ This is the most feature-packed, most thoroughly engineered Roborock plugin for
37
37
  - 📍 **See where it's cleaning — live.** Apple Home shows _"Cleaning — Kitchen"_ with the room the robot is actually inside, updating as it moves from room to room. Works even for cleans started from the robot's button or the Roborock app. No other Homebridge plugin does this.
38
38
  - 🧭 **One robot, one tile — and as many robots as you own.** Sign in once and your whole fleet comes along: every vacuum on your account appears as its own clean, native accessory in Apple Home. No clutter of fake fans and helper switches, and rooms appear with the names you gave them in the Roborock app.
39
39
  - ⚡ **Fast and reliable.** Commands go directly to the robot over your own network whenever possible, with the Roborock cloud as automatic backup — and built-in diagnostics in the settings if you ever want to look under the hood.
40
- - 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 419 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
40
+ - 🛡️ **Verified by Homebridge.** Reviewed and endorsed by the Homebridge team. 463 automated tests, zero known vulnerabilities, no analytics, and a startup designed to never crash your Homebridge — even when your Wi-Fi or the Roborock cloud has a bad day.
41
41
 
42
42
  ## Features
43
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.4.8",
3
+ "version": "3.4.10",
4
4
  "description": "The most complete Roborock plugin for Apple Home. Supports the entire Roborock lineup — from the classic S-series to the new 2025 Q7 series that no other plugin can control. Sign in with your Roborock account and get native start/stop, room cleaning, suction levels, battery, and live 'cleaning in the kitchen' room tracking. Verified by Homebridge.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -186,6 +186,14 @@ function readVarint(buf, pos) {
186
186
  throw new Error("Malformed varint in SCMap payload");
187
187
  }
188
188
 
189
+ // Bounds for the raw field survey below. They exist to keep a diagnostic
190
+ // from becoming a liability: the survey is pointed at fields whose schema is
191
+ // unknown, so it must not be able to produce an unbounded log line, walk the
192
+ // occupancy grid as if it were protobuf, or recurse without end.
193
+ const RAW_SURVEY_MAX_SCALARS = 48;
194
+ const RAW_SURVEY_MAX_SUBMESSAGE_BYTES = 16384;
195
+ const RAW_SURVEY_MAX_DEPTH = 2;
196
+
189
197
  /** @param {Buffer} buf @param {number} pos @param {number} wireType */
190
198
  function skipField(buf, pos, wireType) {
191
199
  switch (wireType) {
@@ -284,6 +292,11 @@ function parseRoomsFromScMap(buffer) {
284
292
  * @returns {{
285
293
  * head: {sizeX: number, sizeY: number, minX: number, minY: number, resolution: number} | null,
286
294
  * pose: {x: number, y: number} | null,
295
+ * rawSurvey: {
296
+ * fields: Array<{field: number, count: number, bytes: number}>,
297
+ * scalars: Record<string, number>,
298
+ * truncated: boolean,
299
+ * },
287
300
  * rooms: Array<{roomId: number, roomName: string}>,
288
301
  * roomChains: Array<{roomId: number, points: Array<{x: number, y: number}>}>,
289
302
  * }}
@@ -293,6 +306,26 @@ function parseScMapLiveState(buffer) {
293
306
  let head = null;
294
307
  /** @type {{x: number, y: number} | null} */
295
308
  let pose = null;
309
+ // Diagnostic. Both Q7s in the field reported a pose of exactly
310
+ // (1100.0, 1100.0) — the same value on two robots, two maps and twelve
311
+ // minutes of active cleaning. That is a constant, not a position, so the
312
+ // number being read as the robot's position is not the robot's position.
313
+ //
314
+ // The schema above is not obviously wrong, which is what makes guessing
315
+ // another field number a bad move: it would be the third guess in a row on
316
+ // this code path. Two candidates fit the evidence and the survey separates
317
+ // them without a second release. DeviceCurrentPoseInfo carries an `update`
318
+ // varint, so field 8 may simply be marked stale on this firmware — a
319
+ // float-only dump would not have shown it. And field 6 is a pose *trail*,
320
+ // whose last point is by construction where the robot is now — which needs
321
+ // one level of nesting to see. So the survey records varints as well as
322
+ // floats, and descends one level: repeated paths overwrite, which leaves
323
+ // the last point of a trail sitting in the log under a stable key.
324
+ /** @type {Array<{field: number, count: number, bytes: number}>} */
325
+ const surveyFields = [];
326
+ /** @type {Record<string, number>} */
327
+ const surveyScalars = {};
328
+ let surveyTruncated = false;
296
329
  /** @type {Array<{roomId: number, roomChainPoints?: unknown}>} */
297
330
  const roomChains = [];
298
331
 
@@ -347,6 +380,109 @@ function parseScMapLiveState(buffer) {
347
380
  : null;
348
381
  }
349
382
 
383
+ /**
384
+ * Record that a top-level field was seen, and how many bytes it carried.
385
+ *
386
+ * The sizes matter as much as the values: a submessage that grows between
387
+ * two consecutive log lines while the robot is driving is a trail of where
388
+ * it has been, and the field that does that is the one worth reading.
389
+ *
390
+ * @param {number} field
391
+ * @param {number} bytes
392
+ */
393
+ function noteField(field, bytes) {
394
+ const seen = surveyFields.find((entry) => entry.field === field);
395
+ if (seen) {
396
+ seen.count += 1;
397
+ seen.bytes += bytes;
398
+ } else if (surveyFields.length < RAW_SURVEY_MAX_SCALARS) {
399
+ surveyFields.push({ field, count: 1, bytes });
400
+ } else {
401
+ surveyTruncated = true;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * @param {string} path
407
+ * @param {number} value
408
+ */
409
+ function noteScalar(path, value) {
410
+ if (
411
+ surveyScalars[path] === undefined &&
412
+ Object.keys(surveyScalars).length >= RAW_SURVEY_MAX_SCALARS
413
+ ) {
414
+ surveyTruncated = true;
415
+ return;
416
+ }
417
+ // Deliberately last-wins. A repeated submessage collapses to its final
418
+ // occurrence, which for a pose trail is the current position.
419
+ surveyScalars[path] = value;
420
+ }
421
+
422
+ /**
423
+ * Walk a submessage recording every scalar it contains, keyed by dotted
424
+ * field path, to a bounded depth.
425
+ *
426
+ * Diagnostic only, and defensive by necessity: it is pointed at fields
427
+ * whose schema is unknown, so bytes that are not protobuf at all will
428
+ * reach it. A throw here would take live-room tracking down with it, so
429
+ * the caller swallows the error and keeps whatever was collected.
430
+ *
431
+ * @param {Buffer} buf
432
+ * @param {string} prefix
433
+ * @param {number} depth
434
+ */
435
+ function surveyMessage(buf, prefix, depth) {
436
+ let pos = 0;
437
+ while (pos < buf.length) {
438
+ const tag = readVarint(buf, pos);
439
+ pos = tag.pos;
440
+ const fieldNumber = Math.floor(tag.value / 8);
441
+ const wireType = tag.value % 8;
442
+ const path = `${prefix}.${fieldNumber}`;
443
+ if (wireType === 0) {
444
+ const value = readVarint(buf, pos);
445
+ pos = value.pos;
446
+ noteScalar(path, value.value);
447
+ } else if (wireType === 5) {
448
+ noteScalar(path, buf.readFloatLE(pos));
449
+ pos += 4;
450
+ } else if (wireType === 1) {
451
+ noteScalar(path, buf.readDoubleLE(pos));
452
+ pos += 8;
453
+ } else if (wireType === 2) {
454
+ const len = readVarint(buf, pos);
455
+ if (depth > 1 && len.value > 0) {
456
+ surveyMessage(
457
+ buf.subarray(len.pos, len.pos + len.value),
458
+ path,
459
+ depth - 1
460
+ );
461
+ }
462
+ pos = len.pos + len.value;
463
+ } else {
464
+ pos = skipField(buf, pos, wireType);
465
+ }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * @param {Buffer} buf
471
+ * @param {number} field
472
+ */
473
+ function surveyTopLevelSubmessage(buf, field) {
474
+ if (buf.length > RAW_SURVEY_MAX_SUBMESSAGE_BYTES) {
475
+ // The occupancy grid is tens of kilobytes of raw cells, not protobuf.
476
+ // Its size is recorded; walking it would be nonsense and slow.
477
+ return;
478
+ }
479
+ try {
480
+ surveyMessage(buf, String(field), RAW_SURVEY_MAX_DEPTH);
481
+ } catch {
482
+ surveyTruncated = true;
483
+ }
484
+ }
485
+
350
486
  /** @param {Buffer} buf */
351
487
  function parseChainPoint(buf) {
352
488
  const point = { x: 0, y: 0 };
@@ -440,6 +576,8 @@ function parseScMapLiveState(buffer) {
440
576
  if (wireType === 2) {
441
577
  const len = readVarint(buffer, pos);
442
578
  const body = buffer.subarray(len.pos, len.pos + len.value);
579
+ noteField(fieldNumber, len.value);
580
+ surveyTopLevelSubmessage(body, fieldNumber);
443
581
  if (fieldNumber === 3) {
444
582
  head = parseMapHead(body);
445
583
  } else if (fieldNumber === 8) {
@@ -457,6 +595,17 @@ function parseScMapLiveState(buffer) {
457
595
  }
458
596
  pos = len.pos + len.value;
459
597
  } else {
598
+ // A position could just as easily be a bare float or varint on the
599
+ // RobotMap itself; the loop above only ever looked at submessages, so
600
+ // such a field would never have been seen at all.
601
+ noteField(fieldNumber, 0);
602
+ if (wireType === 0) {
603
+ noteScalar(String(fieldNumber), readVarint(buffer, pos).value);
604
+ } else if (wireType === 5) {
605
+ noteScalar(String(fieldNumber), buffer.readFloatLE(pos));
606
+ } else if (wireType === 1) {
607
+ noteScalar(String(fieldNumber), buffer.readDoubleLE(pos));
608
+ }
460
609
  pos = skipField(buffer, pos, wireType);
461
610
  }
462
611
  }
@@ -464,6 +613,11 @@ function parseScMapLiveState(buffer) {
464
613
  return {
465
614
  head,
466
615
  pose,
616
+ rawSurvey: {
617
+ fields: surveyFields,
618
+ scalars: surveyScalars,
619
+ truncated: surveyTruncated,
620
+ },
467
621
  rooms,
468
622
  roomChains:
469
623
  /** @type {Array<{roomId: number, points: Array<{x: number, y: number}>}>} */ (
@@ -504,7 +658,9 @@ function resolveLiveRoomId(liveState) {
504
658
  * @returns {{roomId: number | null,
505
659
  * reason: "resolved" | "no-map-header" | "no-pose" | "no-room-outlines" | "pose-outside-outlines",
506
660
  * outlineCount: number,
507
- * cell: {x: number, y: number} | null}}
661
+ * cell: {x: number, y: number} | null,
662
+ * outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
663
+ * head?: {minX: number, minY: number, resolution: number}}}
508
664
  */
509
665
  function describeLiveRoomResolution(liveState) {
510
666
  const head = liveState?.head;
@@ -541,9 +697,44 @@ function describeLiveRoomResolution(liveState) {
541
697
  reason: "pose-outside-outlines",
542
698
  outlineCount,
543
699
  cell,
700
+ outlineBounds: outlineBoundingBox(chains),
701
+ head: { minX: head.minX, minY: head.minY, resolution },
544
702
  };
545
703
  }
546
704
 
705
+ /**
706
+ * Bounding box of every room outline, in the same cell space the
707
+ * point-in-polygon test uses.
708
+ *
709
+ * Field logs showed two Q7s reporting position cells around 22,000 while a
710
+ * Roborock map is at most a couple of thousand cells across — so the position
711
+ * is not "between rooms", it is nowhere near the map. Whether that is a unit
712
+ * mismatch (pose in millimetres against a resolution in metres) or a wrong
713
+ * origin cannot be told from the position alone: it needs the range the
714
+ * outlines actually occupy. Printing both next to each other turns a guess
715
+ * into a measurement.
716
+ *
717
+ * @param {Array<{points: Array<{x: number, y: number}>}>} chains
718
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number} | null}
719
+ */
720
+ function outlineBoundingBox(chains) {
721
+ let minX = Infinity;
722
+ let minY = Infinity;
723
+ let maxX = -Infinity;
724
+ let maxY = -Infinity;
725
+
726
+ for (const chain of chains) {
727
+ for (const point of chain.points || []) {
728
+ if (point.x < minX) minX = point.x;
729
+ if (point.y < minY) minY = point.y;
730
+ if (point.x > maxX) maxX = point.x;
731
+ if (point.y > maxY) maxY = point.y;
732
+ }
733
+ }
734
+
735
+ return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
736
+ }
737
+
547
738
  /**
548
739
  * Standard ray-casting point-in-polygon test over a room boundary chain.
549
740
  * @param {number} x @param {number} y
@@ -95,6 +95,92 @@ const B01_LIVE_ROOM_MISS_REASONS = {
95
95
  "the robot's position did not fall inside any known room outline (it may be between rooms, or the map may still be building)",
96
96
  };
97
97
 
98
+ /**
99
+ * The outline range and map origin, appended to a live-room miss.
100
+ *
101
+ * A position cell on its own cannot distinguish "the robot is between rooms"
102
+ * from "the position was computed in the wrong units" — and the field logs
103
+ * showed cells near 22,000 where a Roborock map is a couple of thousand cells
104
+ * at most. Printing the range the outlines occupy, plus the origin and
105
+ * resolution the transform used, makes the difference measurable from one log
106
+ * line instead of inferable from none.
107
+ *
108
+ * @param {{outlineBounds?: {minX: number, minY: number, maxX: number, maxY: number} | null,
109
+ * head?: {minX: number, minY: number, resolution: number}}} resolution
110
+ * @returns {string}
111
+ */
112
+ function describeOutlineBounds(resolution) {
113
+ const bounds = resolution?.outlineBounds;
114
+ if (!bounds) {
115
+ return "";
116
+ }
117
+
118
+ const head = resolution.head;
119
+ const origin = head
120
+ ? `, map origin ${head.minX},${head.minY} at ${head.resolution}/cell`
121
+ : "";
122
+
123
+ return `, outlines span ${Math.round(bounds.minX)}-${Math.round(bounds.maxX)} x ${Math.round(bounds.minY)}-${Math.round(bounds.maxY)}${origin}`;
124
+ }
125
+
126
+ /**
127
+ * The raw SCMap fields behind a live-room miss.
128
+ *
129
+ * Two Q7s reported a position of exactly (1100, 1100) — the same value on two
130
+ * robots, two maps and twelve minutes of active cleaning. A constant is not a
131
+ * position, so the number being read as the robot's position is not the
132
+ * robot's position.
133
+ *
134
+ * Rather than guess another field number, this prints what the payload
135
+ * actually contains: the size of every top-level field and every scalar in
136
+ * the small ones. Two consecutive lines are then a diff — the value that
137
+ * changed while the robot was driving is the position, and the submessage
138
+ * that grew is the trail it left. That turns the next fix into a reading
139
+ * rather than a fourth guess.
140
+ *
141
+ * @param {{rawSurvey?: {fields?: Array<{field: number, count: number, bytes: number}>,
142
+ * scalars?: Record<string, number>,
143
+ * truncated?: boolean} | null}} parsed
144
+ * @returns {string}
145
+ */
146
+ function describeRawMapFields(parsed) {
147
+ const survey = parsed?.rawSurvey;
148
+ const fields = survey?.fields;
149
+ if (!Array.isArray(fields) || fields.length === 0) {
150
+ return "";
151
+ }
152
+
153
+ const shape = fields
154
+ .map(
155
+ (entry) =>
156
+ `${entry.field}:${entry.bytes}B${entry.count > 1 ? `x${entry.count}` : ""}`
157
+ )
158
+ .join(" ");
159
+
160
+ const scalars = Object.entries(survey?.scalars || {})
161
+ .map(([path, value]) => `${path}=${formatSurveyScalar(value)}`)
162
+ .join(" ");
163
+
164
+ return `, map fields ${shape}${scalars ? `, scalars ${scalars}` : ""}${
165
+ survey?.truncated ? " (truncated)" : ""
166
+ }`;
167
+ }
168
+
169
+ /**
170
+ * A survey value short enough to sit in a log line, precise enough to see a
171
+ * robot move. Three decimals of a metre is a millimetre; three decimals of a
172
+ * millimetre is far below anything a vacuum reports.
173
+ *
174
+ * @param {number} value
175
+ * @returns {string}
176
+ */
177
+ function formatSurveyScalar(value) {
178
+ if (!Number.isFinite(value)) {
179
+ return String(value);
180
+ }
181
+ return Number.isInteger(value) ? String(value) : value.toFixed(3);
182
+ }
183
+
98
184
  const B01_STATUS_TICK_MS = 15000;
99
185
  const B01_STATUS_FORCED_GAP_MS = 1500;
100
186
  const B01_STATUS_ACTIVE_GAP_MS = 12000;
@@ -4291,7 +4377,7 @@ class Roborock {
4291
4377
  // because they call for different fixes.
4292
4378
  liveState.unresolvedPoseCount =
4293
4379
  (liveState.unresolvedPoseCount || 0) + 1;
4294
- const message = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (attempt ${liveState.unresolvedPoseCount} this run, ${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}).`;
4380
+ const message = `Live room for ${this.describeDevice(duid)}: ${B01_LIVE_ROOM_MISS_REASONS[resolution2.reason]} (attempt ${liveState.unresolvedPoseCount} this run, ${resolution2.outlineCount} room outline(s) in the map${resolution2.cell ? `, position cell ${Math.round(resolution2.cell.x)},${Math.round(resolution2.cell.y)}` : ""}${describeOutlineBounds(resolution2)}${describeRawMapFields(parsed)}).`;
4295
4381
  if (liveState.unresolvedPoseCount % 5 === 0) {
4296
4382
  this.log.info(message);
4297
4383
  } else {