matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
466 lines • 25.2 kB
JavaScript
import crypto from 'node:crypto';
import zlib from 'node:zlib';
import * as protobuf from 'protobufjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { B01MapParser } from '../../../../roborockCommunication/map/b01/b01MapParser.js';
import { ROBOROCK_PROTO_STR } from '../../../../roborockCommunication/map/b01/roborockProto.js';
function encodeRobotMap(fields) {
const root = protobuf.parse(ROBOROCK_PROTO_STR).root;
const robotMapType = root.lookupType('SCMap.RobotMap');
const message = robotMapType.create(fields);
return Buffer.from(robotMapType.encode(message).finish());
}
describe('B01MapParser', () => {
let parser;
beforeEach(() => {
parser = new B01MapParser();
});
describe('parseRooms', () => {
it('returns empty rooms and mapId when roomDataInfo is absent', () => {
const buffer = encodeRobotMap({ mapType: 1, mapHead: { mapHeadId: 42 } });
const result = parser.parseRooms(buffer);
expect(result.rooms).toEqual([]);
expect(result.mapId).toBe(42);
});
it('returns empty rooms when roomDataInfo is empty array', () => {
const buffer = encodeRobotMap({ mapType: 1, roomDataInfo: [] });
const result = parser.parseRooms(buffer);
expect(result.rooms).toEqual([]);
});
it('maps roomDataInfo fields to B01RoomInfo correctly', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 10, roomName: 'Living Room', roomTypeId: 3, colorId: 7 }],
});
const result = parser.parseRooms(buffer);
expect(result.rooms).toHaveLength(1);
expect(result.rooms[0]).toMatchObject({ roomId: 10, roomName: 'Living Room', roomTypeId: 3, colorId: 7 });
});
it('includes labelPos when roomNamePost is present', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'Room', roomNamePost: { x: 5.0, y: 10.0 } }],
});
const result = parser.parseRooms(buffer);
expect(result.rooms[0].labelPos).toEqual({ x: 5, y: 10 });
});
it('omits labelPos when roomNamePost is absent', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 2, roomName: 'Room2' }],
});
const result = parser.parseRooms(buffer);
expect(result.rooms[0].labelPos).toBeUndefined();
});
it('extracts mapId from mapHead.mapHeadId when > 0', () => {
const buffer = encodeRobotMap({
mapType: 1,
mapHead: { mapHeadId: 100 },
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
});
const result = parser.parseRooms(buffer);
expect(result.mapId).toBe(100);
});
it('returns undefined mapId when mapHeadId === 0', () => {
const buffer = encodeRobotMap({
mapType: 1,
mapHead: { mapHeadId: 0 },
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
});
const result = parser.parseRooms(buffer);
expect(result.mapId).toBeUndefined();
});
});
describe('parseRooms — currentPose decode', () => {
it('decodes currentPose correctly when x/y/phi are all present', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
currentPose: { x: 123.4, y: 567.8, phi: 1.57 },
});
const result = parser.parseRooms(buffer);
expect(result.currentPose?.x).toBeCloseTo(123.4, 1);
expect(result.currentPose?.y).toBeCloseTo(567.8, 1);
expect(result.currentPose?.phi).toBeCloseTo(1.57, 2);
});
it('decodes currentPose with phi omitted — proto3 float defaults to 0, x/y still populated', () => {
// proto3 scalar fields (float) always decode to their zero-value (0) rather than
// `undefined` when omitted from the encoded input — there is no wire-level presence
// tracking for non-message scalar fields in proto3. `phi` is therefore 0, not
// undefined, in this case; the `undefined` branch of the typeof guard in
// b01MapParser.ts is exercised instead by non-number values (see the defensive-guard
// test below), not by omission.
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
currentPose: { x: 10, y: 20 },
});
const result = parser.parseRooms(buffer);
expect(result.currentPose?.x).toBeCloseTo(10, 1);
expect(result.currentPose?.y).toBeCloseTo(20, 1);
expect(result.currentPose?.phi).toBe(0);
});
it('returns currentPose undefined when the currentPose field is entirely absent', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
});
const result = parser.parseRooms(buffer);
expect(result.currentPose).toBeUndefined();
});
it('returns currentPose undefined when x/y are not numbers (defensive guard)', () => {
// The proto schema encodes x/y as floats, so a non-number can't survive real protobuf
// encode/decode. Exercise the runtime typeof guard directly against a decoded-shaped
// object to verify the defensive check behaves safely for malformed/loosely-typed input.
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
});
const decodeSpy = parser.robotMapType.decode(buffer);
decodeSpy.currentPose = { x: 'not-a-number', y: 20 };
// Directly verify the guard logic mirrors parseRooms' defensive typeof checks.
expect(typeof decodeSpy.currentPose).toBe('object');
const currentPoseRaw = decodeSpy.currentPose;
expect(typeof currentPoseRaw.x === 'number' && typeof currentPoseRaw.y === 'number').toBe(false);
});
});
describe('parseRooms — roomMatrix decode', () => {
it('decodes roomMatrix correctly when matrix bytes are present', () => {
const matrixBytes = Buffer.from([1, 2, 3, 4, 5]);
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
roomMatrix: { matrix: matrixBytes },
});
const result = parser.parseRooms(buffer);
expect(Buffer.isBuffer(result.roomMatrix?.data)).toBe(true);
expect(result.roomMatrix?.data).toEqual(matrixBytes);
});
it('returns roomMatrix undefined when the roomMatrix field is absent', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
});
const result = parser.parseRooms(buffer);
expect(result.roomMatrix).toBeUndefined();
});
it('returns roomMatrix undefined when matrix bytes are zero-length', () => {
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [{ roomId: 1, roomName: 'R' }],
roomMatrix: { matrix: Buffer.alloc(0) },
});
const result = parser.parseRooms(buffer);
expect(result.roomMatrix).toBeUndefined();
});
});
describe('parseRooms — currentPose/roomMatrix independent of roomDataInfo', () => {
it('still populates currentPose/roomMatrix when roomDataInfo is empty (early-return branch)', () => {
const matrixBytes = Buffer.from([9, 8, 7]);
const buffer = encodeRobotMap({
mapType: 1,
roomDataInfo: [],
currentPose: { x: 1, y: 2, phi: 0.5 },
roomMatrix: { matrix: matrixBytes },
});
const result = parser.parseRooms(buffer);
expect(result.rooms).toEqual([]);
expect(result.currentPose?.x).toBeCloseTo(1, 1);
expect(result.currentPose?.y).toBeCloseTo(2, 1);
expect(result.currentPose?.phi).toBeCloseTo(0.5, 2);
expect(result.roomMatrix?.data).toEqual(matrixBytes);
});
});
describe('decodeBase64IfNeeded', () => {
it('decodes Base64-encoded data when sample matches Base64 pattern', () => {
const raw = Buffer.from('hello world');
const base64 = Buffer.from(raw.toString('base64'), 'utf8');
const result = parser.decodeBase64IfNeeded(base64);
expect(result.toString('utf8')).toBe('hello world');
});
it('returns raw buffer when data is not Base64-encoded (binary data)', () => {
// Binary data with bytes > 127 won't match the Base64 char pattern
const raw = Buffer.from([0x00, 0xff, 0x80, 0x7f, 0x12]);
const result = parser.decodeBase64IfNeeded(raw);
expect(result).toEqual(raw);
});
});
describe('asciiHexToBinaryIfNeeded', () => {
it('converts ASCII hex data starting with "78" to binary', () => {
// zlib deflate magic bytes start with 0x78
const rawBinary = Buffer.from([0x78, 0x9c, 0x01, 0x00]);
const hexString = rawBinary.toString('hex'); // "789c0100"
const hexBuffer = Buffer.from(hexString, 'utf8');
const result = parser.asciiHexToBinaryIfNeeded(hexBuffer);
expect(result[0]).toBe(0x78);
expect(result[1]).toBe(0x9c);
});
it('returns data unchanged when not hex-encoded', () => {
// Starts with non-hex prefix so detection fails
const raw = Buffer.from('not hex data here!!');
const result = parser.asciiHexToBinaryIfNeeded(raw);
expect(result).toEqual(raw);
});
});
describe('decryptIfNeeded', () => {
it('skips decryption when data.length % 16 !== 0', () => {
const data = Buffer.from([1, 2, 3]); // length 3, not multiple of 16
const result = parser.decryptIfNeeded(data, 'MODEL', 'SERIAL');
expect(result).toEqual(data);
});
it('applies AES-128-ECB decryption when data.length % 16 === 0', () => {
// Encrypt a known block with PKCS7 padding so decryptIfNeeded (setAutoPadding=true) can decrypt it
const modelShortCode = 'S7';
const serial = 'ABC12345';
const key = parser.deriveEncryptionKey(modelShortCode, serial);
const plaintext = Buffer.from('test plaintext!!'); // exactly 16 bytes
// Use setAutoPadding(true) on encrypt so decrypt with setAutoPadding(true) works
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
cipher.setAutoPadding(true);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
// encrypted will be 32 bytes (16 data + 16 PKCS7 padding block)
expect(encrypted.length % 16).toBe(0);
const result = parser.decryptIfNeeded(encrypted, modelShortCode, serial);
expect(result.toString('utf8')).toBe('test plaintext!!');
});
});
describe('deriveEncryptionKey', () => {
it('produces consistent key for same modelShortCode + serial', () => {
const key1 = parser.deriveEncryptionKey('S7', 'SN001');
const key2 = parser.deriveEncryptionKey('S7', 'SN001');
expect(key1).toEqual(key2);
});
it('produces different keys for different serial numbers', () => {
const key1 = parser.deriveEncryptionKey('S7', 'SN001');
const key2 = parser.deriveEncryptionKey('S7', 'SN002');
expect(key1).not.toEqual(key2);
});
});
describe('parseRoomsFromEncryptedBinary — round-trip (no encryption)', () => {
it('parses valid compressed binary with no encryption/encoding', () => {
// Build a protobuf buffer, deflate it, then parse it back
const protoBuffer = encodeRobotMap({
mapType: 1,
mapHead: { mapHeadId: 7 },
roomDataInfo: [{ roomId: 99, roomName: 'TestRoom' }],
});
const compressed = zlib.deflateSync(protoBuffer);
// Pass as raw binary (non-base64, non-hex) and non-16-aligned length
// Ensure length is NOT multiple of 16 to skip decryption
const nonMultiple = compressed.length % 16 === 0 ? Buffer.concat([compressed, Buffer.from([0x01])]) : compressed;
const result = parser.parseRoomsFromEncryptedBinary(nonMultiple, 'MODEL', 'SERIAL');
expect(result.rooms.some((r) => r.roomId === 99 && r.roomName === 'TestRoom')).toBe(true);
});
});
describe('parseRoomsFromEncryptedBinary — Q10 routing', () => {
it('routes buffer starting with 0x01 0x01 marker to Q10 path', () => {
// Build a synthetic Q10-shaped packet with a valid minimal LZ4 block
// Marker: 0x01 0x01
// MapId: 42 (u32be @2)
// Width: 1 (u16be @7)
// Height: 1 (u16be @9)
// CompressedLength: u16be @27
// LZ4 block @29: token 0x20 (2 literals, 0 match) + grid byte + room marker
// Grid: 1x1 = 1 byte (0x00)
// Room section: marker (0x01) + room count (0x00)
// So LZ4 uncompresses to: [0x00, 0x01, 0x00]
const lz4Block = Buffer.from([
0x30, // token: literalLen=3, matchLen=0
0x00, // grid byte
0x01, // room section marker
0x00, // room count
]);
const q10Packet = Buffer.alloc(29 + lz4Block.length);
q10Packet[0] = 0x01;
q10Packet[1] = 0x01;
q10Packet.writeUInt32BE(42, 2);
q10Packet.writeUInt16BE(1, 7);
q10Packet.writeUInt16BE(1, 9);
q10Packet.writeUInt16BE(lz4Block.length, 27);
lz4Block.copy(q10Packet, 29);
const result = parser.parseRoomsFromEncryptedBinary(q10Packet, 'MODEL', 'SERIAL');
expect(result.mapId).toBe(42);
expect(result.rooms).toHaveLength(0); // No rooms in this minimal packet
});
it('throws distinguishable error for malformed Q10 content (not zlib error)', () => {
// Buffer starts with 0x01 0x01 (Q10 marker) but has invalid content
// (width=0, which should trigger a Q10-specific error)
const badQ10Packet = Buffer.alloc(30);
badQ10Packet[0] = 0x01;
badQ10Packet[1] = 0x01;
badQ10Packet.writeUInt32BE(1, 2); // mapId
badQ10Packet.writeUInt16BE(0, 7); // width = 0 (invalid)
badQ10Packet.writeUInt16BE(1, 9); // height
badQ10Packet.writeUInt16BE(1, 27); // compressed length
// Verify the error message is Q10-specific, not the original zlib error
expect(() => {
parser.parseRoomsFromEncryptedBinary(badQ10Packet, 'MODEL', 'SERIAL');
}).toThrow(/Q10 map binary parse failed/);
});
it('preserves Q7 round-trip by not intercepting zlib-shaped payloads', () => {
// This regression test verifies that existing Q7 payloads (which start with zlib magic byte 0x78)
// are not intercepted by the Q10 classifier and continue through the untouched Q7 path.
// The zlib magic byte (0x78) never coincidentally equals 0x01, so this is safe.
const protoBuffer = encodeRobotMap({
mapType: 1,
mapHead: { mapHeadId: 7 },
roomDataInfo: [{ roomId: 99, roomName: 'TestRoom' }],
});
const compressed = zlib.deflateSync(protoBuffer);
// Ensure the first byte is the zlib magic byte 0x78
expect(compressed[0]).toBe(0x78);
// Ensure it's NOT 0x01 (Q10 marker)
expect(compressed[0]).not.toBe(0x01);
const nonMultiple = compressed.length % 16 === 0 ? Buffer.concat([compressed, Buffer.from([0x01])]) : compressed;
const result = parser.parseRoomsFromEncryptedBinary(nonMultiple, 'MODEL', 'SERIAL');
// Verify the Q7 path was taken by checking the decoded protobuf result
expect(result.rooms.some((r) => r.roomId === 99 && r.roomName === 'TestRoom')).toBe(true);
});
it('classifier correctly identifies Q10 payload shape (0x01 0x01 marker)', () => {
// Directly test the private isQ10ShapedPayload method via cast pattern
const q10Buffer = Buffer.from([0x01, 0x01, 0x00, 0x00]);
const isQ10 = parser.isQ10ShapedPayload(q10Buffer);
expect(isQ10).toBe(true);
// Test non-Q10 shape (zlib-like)
const zlibBuffer = Buffer.from([0x78, 0x9c, 0x00, 0x00]);
const isQ10ZLib = parser.isQ10ShapedPayload(zlibBuffer);
expect(isQ10ZLib).toBe(false);
// Test buffer too short
const shortBuffer = Buffer.from([0x01]);
const isQ10Short = parser.isQ10ShapedPayload(shortBuffer);
expect(isQ10Short).toBe(false);
});
});
describe('parseRoomsFromEncryptedBinary — Trace packet routing', () => {
it('routes buffer starting with 0x02 0x01 marker to trace path', () => {
// Build a synthetic trace packet
// Marker: 0x02 0x01
// Header (10 bytes total) with session counter at offset 3
// Body: single point pair (x=100, y=200) at offsets 10-13
const tracePacket = Buffer.alloc(14);
tracePacket[0] = 0x02;
tracePacket[1] = 0x01;
tracePacket[3] = 5; // session counter
tracePacket.writeInt16BE(100, 10);
tracePacket.writeInt16BE(200, 12);
const result = parser.parseRoomsFromEncryptedBinary(tracePacket, 'MODEL', 'SERIAL');
// Verify trace packet routing: empty rooms, no mapId, currentPose set from last point
expect(result.rooms).toEqual([]);
expect(result.mapId).toBeUndefined();
expect(result.currentPose).toEqual({ x: 100, y: 200 });
expect(result.roomMatrix).toBeUndefined();
});
it('classifier correctly identifies trace packet shape (0x02 0x01 marker)', () => {
// Directly test the private isTracePacket method via cast pattern
const traceBuffer = Buffer.from([0x02, 0x01, 0x00, 0x00]);
const isTrace = parser.isTracePacket(traceBuffer);
expect(isTrace).toBe(true);
// Test non-trace shape (Q10-shaped)
const q10Buffer = Buffer.from([0x01, 0x01, 0x00, 0x00]);
const isTraceQ10 = parser.isTracePacket(q10Buffer);
expect(isTraceQ10).toBe(false);
// Test non-trace shape (zlib-like)
const zlibBuffer = Buffer.from([0x78, 0x9c, 0x00, 0x00]);
const isTraceZLib = parser.isTracePacket(zlibBuffer);
expect(isTraceZLib).toBe(false);
// Test buffer too short
const shortBuffer = Buffer.from([0x02]);
const isTraceShort = parser.isTracePacket(shortBuffer);
expect(isTraceShort).toBe(false);
});
it('returns undefined currentPose when trace packet has no body (header only)', () => {
// Minimal trace packet: header only (10 bytes), no point body
const tracePacket = Buffer.alloc(10);
tracePacket[0] = 0x02;
tracePacket[1] = 0x01;
const result = parser.parseRoomsFromEncryptedBinary(tracePacket, 'MODEL', 'SERIAL');
expect(result.currentPose).toBeUndefined();
});
it('handles multiple points in trace packet, returning last as currentPose', () => {
// Build trace packet with 3 points
const tracePacket = Buffer.alloc(22);
tracePacket[0] = 0x02;
tracePacket[1] = 0x01;
tracePacket.writeInt16BE(10, 10);
tracePacket.writeInt16BE(20, 12);
tracePacket.writeInt16BE(30, 14);
tracePacket.writeInt16BE(40, 16);
tracePacket.writeInt16BE(50, 18);
tracePacket.writeInt16BE(60, 20);
const result = parser.parseRoomsFromEncryptedBinary(tracePacket, 'MODEL', 'SERIAL');
// Last point should be (50, 60)
expect(result.currentPose).toEqual({ x: 50, y: 60 });
});
it('throws distinguishable error for malformed trace packet (invalid marker)', () => {
// Buffer starts with 0x02 0x01 (trace marker) but is too short
const malformedPacket = Buffer.alloc(5);
malformedPacket[0] = 0x02;
malformedPacket[1] = 0x01;
expect(() => {
parser.parseRoomsFromEncryptedBinary(malformedPacket, 'MODEL', 'SERIAL');
}).toThrow(/Q10 trace packet parse failed.*marker 0x02 0x01/);
});
it('throws distinguishable error for trace packet with non-4-byte-aligned body', () => {
// Buffer starts with 0x02 0x01 (trace marker) but has 5 bytes in body (not divisible by 4)
const malformedPacket = Buffer.alloc(15);
malformedPacket[0] = 0x02;
malformedPacket[1] = 0x01;
expect(() => {
parser.parseRoomsFromEncryptedBinary(malformedPacket, 'MODEL', 'SERIAL');
}).toThrow(/Q10 trace packet parse failed/);
});
it('wraps trace packet parse errors with distinguishable message', () => {
const malformedPacket = Buffer.alloc(15);
malformedPacket[0] = 0x02;
malformedPacket[1] = 0x01;
expect(() => {
parser.parseRoomsFromEncryptedBinary(malformedPacket, 'MODEL', 'SERIAL');
}).toThrow(/Q10 trace packet parse failed.*marker 0x02 0x01/);
});
it('prioritizes Q10 shape over trace shape (Q10 check runs first)', () => {
// This is a priority-order test: if somehow a packet matched both Q10 and trace patterns
// (which shouldn't happen with their distinct markers), Q10 should be checked first.
// Since Q10 uses 0x01 0x01 and trace uses 0x02 0x01, they can't both match.
// However, we can verify that the routing order calls isQ10ShapedPayload before isTracePacket
// by observing that a Q10-shaped packet returns Q10-result, not trace-result.
// Build minimal valid Q10 packet with LZ4 block
const lz4Block = Buffer.from([
0x10, // token: 1 literal, 0 match
0x01, // grid byte
0x01, // room section marker
0x00, // room count
]);
const q10Packet = Buffer.alloc(29 + lz4Block.length);
q10Packet[0] = 0x01;
q10Packet[1] = 0x01;
q10Packet.writeUInt32BE(99, 2); // mapId = 99 to distinguish from trace path
q10Packet.writeUInt16BE(1, 7); // width = 1
q10Packet.writeUInt16BE(1, 9); // height = 1
q10Packet.writeUInt16BE(lz4Block.length, 27); // compressed length
lz4Block.copy(q10Packet, 29);
const result = parser.parseRoomsFromEncryptedBinary(q10Packet, 'MODEL', 'SERIAL');
// Q10 path should set mapId
expect(result.mapId).toBe(99);
});
it('trace packet routing does not interfere with Q7 path (zlib-shaped payloads still work)', () => {
// Build a valid Q7 zlib-compressed protobuf payload
const protoBuffer = encodeRobotMap({
mapType: 1,
mapHead: { mapHeadId: 88 },
roomDataInfo: [{ roomId: 77, roomName: 'TestQRoom' }],
});
const compressed = zlib.deflateSync(protoBuffer);
// Ensure it doesn't start with 0x02 0x01 (trace) or 0x01 0x01 (Q10)
expect(compressed[0]).toBe(0x78); // zlib magic byte
expect(compressed[0]).not.toBe(0x02);
expect(compressed[0]).not.toBe(0x01);
// Make sure it's not 16-byte-aligned so decryption is skipped
const nonMultiple = compressed.length % 16 === 0 ? Buffer.concat([compressed, Buffer.from([0x01])]) : compressed;
const result = parser.parseRoomsFromEncryptedBinary(nonMultiple, 'MODEL', 'SERIAL');
// Q7 path should still work: verify the decoded protobuf result
expect(result.rooms.some((r) => r.roomId === 77 && r.roomName === 'TestQRoom')).toBe(true);
expect(result.mapId).toBe(88);
});
});
});
//# sourceMappingURL=b01MapParser.test.js.map