UNPKG

matterbridge-roborock-vacuum-plugin

Version:
466 lines 25.2 kB
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