homebridge-roborock-matter 3.4.9 → 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,17 @@
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
+
3
15
  ## 3.4.9
4
16
 
5
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-matter",
3
- "version": "3.4.9",
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}>}>} */ (
@@ -123,6 +123,64 @@ function describeOutlineBounds(resolution) {
123
123
  return `, outlines span ${Math.round(bounds.minX)}-${Math.round(bounds.maxX)} x ${Math.round(bounds.minY)}-${Math.round(bounds.maxY)}${origin}`;
124
124
  }
125
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
+
126
184
  const B01_STATUS_TICK_MS = 15000;
127
185
  const B01_STATUS_FORCED_GAP_MS = 1500;
128
186
  const B01_STATUS_ACTIVE_GAP_MS = 12000;
@@ -4319,7 +4377,7 @@ class Roborock {
4319
4377
  // because they call for different fixes.
4320
4378
  liveState.unresolvedPoseCount =
4321
4379
  (liveState.unresolvedPoseCount || 0) + 1;
4322
- 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)}).`;
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)}).`;
4323
4381
  if (liveState.unresolvedPoseCount % 5 === 0) {
4324
4382
  this.log.info(message);
4325
4383
  } else {