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 +12 -0
- package/package.json +1 -1
- package/roborockLib/lib/b01Q7Adapter.js +154 -0
- package/roborockLib/roborockAPI.js +59 -1
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.
|
|
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 {
|