UNPKG

iobroker.roborock

Version:
223 lines (192 loc) 7.44 kB
/** * B01 Protocol 300/301: Distinguishes Photo vs Map and assembles chunked maps. * - Photo: decrypted payload starts with "ROBOROCK" * - Map: otherwise. Chunked = type 300 (chunk 1) + type 301 chunkIndex 2 (chunk 2). * @see docs/map/B01_Map_Protocol.md */ import type { Roborock } from "../main"; import { MapDecryptor } from "./map/b01/MapDecryptor"; const IS_PHOTO_MAGIC = "ROBOROCK"; const MAP_PAYLOAD_MIN = 500; export type ChunkAssemblerResult = | { type: "photo"; payload: Buffer } | { type: "map"; payload: Buffer } | { type: "map_chunk_buffered" } | { type: "map_incomplete"; reason: string } | { type: "skip" }; interface BufferedChunk { layer1: Buffer; type: number; chunkIndex: number; } interface MapCompletenessResult { complete: boolean; reason: string; type?: number; chunkIndex?: number; payloadLen?: number; missing?: string; } export class B01ChunkAssembler { private adapter: Roborock; private mapChunkBuffer: Map<string, BufferedChunk[]> = new Map(); private chunkTimeouts: Map<string, any> = new Map(); private static readonly CHUNK_WAIT_TIMEOUT_MS = 10000; constructor(adapter: Roborock) { this.adapter = adapter; } /** True if decrypted payload is a photo (starts with ROBOROCK). */ static isPhotoChunk(decryptedPayload: Buffer): boolean { return decryptedPayload.length >= 8 && decryptedPayload.subarray(0, 8).toString("ascii") === IS_PHOTO_MAGIC; } /** True if frame is a map chunk (protocol 300/301, payload > 500). */ private static isMapFrame(protocol: number, payloadLen: number): boolean { return (protocol === 300 || protocol === 301) && payloadLen > MAP_PAYLOAD_MIN; } /** * Processes a 300/301 packet. data.payload is already decrypted by messageParser (Layer1 output). * data.protocol = type, data.seq = chunkIndex. */ async process( duid: string, data: { protocol: number; seq: number; payload: Buffer; payloadLen?: number } ): Promise<ChunkAssemblerResult> { const payload = data.payload as Buffer; const protocol = data.protocol; const chunkIndex = data.seq; const payloadLen = data.payloadLen ?? payload.length; if (!B01ChunkAssembler.isMapFrame(protocol, payloadLen)) { return { type: "skip" }; } if (B01ChunkAssembler.isPhotoChunk(payload)) { return { type: "photo", payload }; } // Map: type 300 = Chunk 1, type 301 + chunkIndex 2 = Chunk 2 const isChunk1 = protocol === 300; const isChunk2 = protocol === 301 && chunkIndex === 2; if (isChunk1 || isChunk2) { const buf = this.mapChunkBuffer.get(duid) ?? []; buf.push({ layer1: Buffer.from(payload), type: protocol, chunkIndex }); buf.sort((a, b) => a.chunkIndex - b.chunkIndex); this.mapChunkBuffer.set(duid, buf); if (buf.length < 2) { this.scheduleChunkTimeout(duid); return { type: "map_chunk_buffered" }; } const chunk1 = buf.find((c) => c.type === 300); const chunk2 = buf.find((c) => c.type === 301 && c.chunkIndex === 2); if (!chunk1 || !chunk2) { this.scheduleChunkTimeout(duid); return { type: "map_chunk_buffered" }; } this.clearChunkTimeout(duid); this.mapChunkBuffer.delete(duid); const layer1Concat = Buffer.concat([chunk1.layer1, chunk2.layer1]); const decrypted = await this.decryptChunkedMap(duid, layer1Concat); if (decrypted) { return { type: "map", payload: decrypted }; } return { type: "map_incomplete", reason: "decryptFromLayer2 failed" }; } // type 301 + chunkIndex 1 = single map (not chunked) if (protocol === 301 && chunkIndex === 1) { const decrypted = await this.decryptSingleMap(duid, payload); if (decrypted) { return { type: "map", payload: decrypted }; } } return { type: "skip" }; } /** Chunk 2 without Chunk 1 – whether we should drop it. */ shouldDropIncompleteChunk2(rawFrame: Buffer): boolean { const r = B01ChunkAssembler.checkMapCompleteness(rawFrame); return !r.complete && r.missing === "Chunk 1 (300)"; } /** Clears buffered chunks for duid (e.g. on timeout). */ clearBuffer(duid: string): void { this.clearChunkTimeout(duid); this.mapChunkBuffer.delete(duid); } private scheduleChunkTimeout(duid: string): void { this.clearChunkTimeout(duid); const timeout = this.adapter.setTimeout(() => { const buf = this.mapChunkBuffer.get(duid) ?? []; const hasChunk1 = buf.some((c) => c.type === 300); const hasChunk2 = buf.some((c) => c.type === 301 && c.chunkIndex === 2); if (!hasChunk1 || !hasChunk2) { const missing = !hasChunk1 ? "Chunk 1 (type 300)" : "Chunk 2 (type 301, seq=2)"; const seen = buf.map((c) => `${c.type}/${c.chunkIndex}`).join(", ") || "none"; this.adapter.rLog("MQTT", duid, "Warn", "B01", "301", `Chunk timeout after ${B01ChunkAssembler.CHUNK_WAIT_TIMEOUT_MS}ms: missing ${missing}. Seen=${seen}`, "warn"); } this.chunkTimeouts.delete(duid); this.mapChunkBuffer.delete(duid); }, B01ChunkAssembler.CHUNK_WAIT_TIMEOUT_MS); this.chunkTimeouts.set(duid, timeout); } private clearChunkTimeout(duid: string): void { const existing = this.chunkTimeouts.get(duid); if (existing) { this.adapter.clearTimeout(existing); this.chunkTimeouts.delete(duid); } } private getB01DecryptParams(duid: string): { serial: string; model: string; localKey: string | undefined } { const devices = this.adapter.http_api.getDevices(); const device = devices.find((d: any) => d.duid === duid); const serial = device?.sn || ""; const model = this.adapter.http_api.getRobotModel(duid) || "roborock.vacuum.a27"; const localKey = this.adapter.http_api.getMatchedLocalKeys().get(duid); return { serial, model, localKey }; } private async decryptChunkedMap(duid: string, layer1Concat: Buffer): Promise<Buffer | null> { const { serial, model, localKey } = this.getB01DecryptParams(duid); const result = MapDecryptor.decryptFromLayer2(layer1Concat, serial, model, duid, this.adapter, localKey); return result && MapDecryptor.isSupportedB01MapPayload(result) ? result : null; } private async decryptSingleMap(duid: string, payload: Buffer): Promise<Buffer | null> { const { serial, model, localKey } = this.getB01DecryptParams(duid); const result = MapDecryptor.decrypt(payload, serial, model, duid, this.adapter, localKey); return result && MapDecryptor.isSupportedB01MapPayload(result) ? result : null; } private static checkMapCompleteness(buf: Buffer): MapCompletenessResult { if (buf.length < 19 || buf.toString("ascii", 0, 3) !== "B01") { return { complete: false, reason: "Not a B01 frame" }; } const type = buf.readUInt16BE(15); const chunkIndex = buf.readUInt32BE(3); const payloadLen = buf.readUInt16BE(17); if (type !== 300 && type !== 301) { return { complete: false, reason: `Type ${type} is not map (300/301)` }; } if (payloadLen <= 500) { return { complete: false, reason: `Payload ${payloadLen} B too small for map` }; } if (type === 300) { return { complete: false, reason: "Chunk 1 (type=300) – needs Chunk 2 (301)", type, chunkIndex, payloadLen, missing: "Chunk 2 (301)", }; } if (chunkIndex === 2) { return { complete: false, reason: "Chunk 2 (chunkIndex=2) – needs Chunk 1 (300)", type, chunkIndex, payloadLen, missing: "Chunk 1 (300)", }; } return { complete: true, reason: `Single map (type=301, chunkIndex=1, payloadLen=${payloadLen})`, type, chunkIndex, payloadLen, }; } }