UNPKG

iobroker.roborock

Version:
474 lines (404 loc) 16.8 kB
import { Parser } from "binary-parser"; import { promisify } from "node:util"; import * as zlib from "node:zlib"; import type { Roborock } from "../main"; import { cryptoEngine } from "./cryptoEngine"; const unzipAsync = promisify(zlib.unzip); // Header Parser for Protocol 300 (Initial Photo Chunk) const proto300HeaderParser = new Parser() .endianess("little") .string("magic", { length: 8, assert: "ROBOROCK" }) // 0-7 .uint32("msgIdRaw") // 8-11 (Full Request ID) .skip(4) // 12-15 (Zeros/Reserved) .uint16("headerLength") // 16-17 .uint16("totalChunks") // 18-19 .uint32("totalSize") // 20-23 .uint16("checksum"); // 24-25 // Header Parser for Protocol 301 (Subsequent Photo Chunks) const proto301HeaderParser = new Parser() .endianess("little") .string("magic", { length: 8, assert: "ROBOROCK" }) // 0-7 .uint32("msgIdRaw") // 8-11 (Full Request ID) .skip(4) // 12-15 (Zeros/Reserved) .uint16("headerLength") // 16-17 .uint16("sequence") // 18-19 .uint32("chunkSize") // 20-23 (NOTE: This is NOT TotalSize for P301) .uint16("checksum"); // 24-25 // Parser for V1 Photo Inner Header const v1PhotoInnerHeaderParser = new Parser() .endianess("little") .uint16("version") .uint16("headerLength") .uint32("type"); const v1PhotoProprietaryHeaderParser = new Parser() .endianess("little") .uint16("width") .uint16("height") .uint32("unknown1") .uint32("classId") .uint16("x1") .uint16("y1") .uint16("x2") .uint16("y2") .uint32("unknown2") .uint32("instanceId"); export interface PhotoRequestData { id: number; chunks: Record<number, Buffer>; expectedSize?: number; lastUpdateTime: number; extracted?: { photo: Buffer; bbox: any | null }; } export class PhotoManager { private adapter: Roborock; private pendingPhotoRequests: Record<string, PhotoRequestData> = {}; // Stores expected msgId for raw streams (P300 with 0 chunks) per DUID private expectedRawStreams: Map<string, { msgIdRaw: number; totalSize: number }> = new Map(); // Buffer for packets arriving before P300 header (Out-of-Order / Early Data) private earlyDataPackets: Map<string, { buffers: Buffer[]; timer: any }> = new Map(); private photoCleanupInterval: any = null; constructor(adapter: Roborock) { this.adapter = adapter; this.photoCleanupInterval = this.adapter.setInterval(() => { const now = Date.now(); for (const key in this.pendingPhotoRequests) { if (now - this.pendingPhotoRequests[key].lastUpdateTime > 60000) { delete this.pendingPhotoRequests[key]; } } }, 30000); } public getPendingPhotoRequests(): Record<string, PhotoRequestData> { return this.pendingPhotoRequests; } public getPendingRequest(duid: string): PhotoRequestData | undefined { const keys = Object.keys(this.pendingPhotoRequests); const key = keys.find((k) => k.startsWith(`${duid}_`)); return key ? this.pendingPhotoRequests[key] : undefined; } /** * Dedicated handler for Protocol 300 (Initial Photo Chunk). * Returns true if the packet was identified as a photo. */ public async handlePhotoProtocol300(duid: string, payloadBuf: Buffer): Promise<boolean> { return this.handleCommonPhotoPacket(duid, payloadBuf, 300); } public async handlePhotoProtocol301(duid: string, payloadBuf: Buffer, forcedMsgId?: number): Promise<boolean> { return this.handleCommonPhotoPacket(duid, payloadBuf, 301, forcedMsgId); } private async handleCommonPhotoPacket(duid: string, payloadBuf: Buffer, protocol: number, forcedMsgId?: number): Promise<boolean> { const magicSearch = payloadBuf.indexOf("ROBOROCK"); const alignedBuf = magicSearch !== -1 ? payloadBuf.subarray(magicSearch) : payloadBuf; let context: { msgIdRaw: number; sequence: number; totalSize: number; headerLength: number; dataSkip: number } | null = null; try { if (magicSearch !== -1) { if (protocol === 300) { const header = proto300HeaderParser.parse(alignedBuf); context = { msgIdRaw: header.msgIdRaw, sequence: 1, // P300 is always the start totalSize: header.totalSize, headerLength: header.headerLength, dataSkip: 26 // Preserve RSA }; // If chunks == 0, expect raw stream (Type 0 behavior) if (header.totalChunks === 0) { // This is for Type 0 photos that come on P300 as raw stream (less common now) await this.expectRawStream(duid, header.msgIdRaw, header.totalSize); } } else { context = this.parseProtocol301Header(duid, alignedBuf); } } else { // Try to match with expected raw stream (Type 0) context = this.matchRawStreamContext(duid); // Fallback: If forced by RequestsHandler match but packet has no internal header if (!context && forcedMsgId) { const requestKey = `${duid}_${forcedMsgId}`; const existing = this.pendingPhotoRequests[requestKey]; const nextSeq = existing ? Object.keys(existing.chunks).length + 1 : 1; context = { msgIdRaw: forcedMsgId, sequence: nextSeq, totalSize: 0, headerLength: 0, dataSkip: 0 }; } } if (context) { const { msgIdRaw, sequence, totalSize, headerLength, dataSkip } = context; const requestKey = `${duid}_${msgIdRaw}`; let photoData = this.pendingPhotoRequests[requestKey]; if (!photoData) { // Check if this request is still pending in global queue if (!this.adapter.pendingRequests.has(msgIdRaw as any)) return true; // Calculation: expectedSize = payloadSize + (actualHeaderReserved - 26) const expectedSize = totalSize > 0 ? totalSize + (headerLength > 0 ? headerLength - 26 : 0) : 0; photoData = this.initializePhotoRequest(msgIdRaw, expectedSize); this.pendingPhotoRequests[requestKey] = photoData; } if (headerLength > 0 && totalSize > 0 && !photoData.expectedSize) { photoData.expectedSize = totalSize + (headerLength - 26); } // Sequence is 1-based, we store as 0-based index photoData.chunks[sequence - 1] = alignedBuf.subarray(dataSkip); photoData.lastUpdateTime = Date.now(); // Register as expected raw stream if not complete (ensures P301 without headers match this DUID) const isComplete = await this.isPhotoComplete(photoData); if (!isComplete && msgIdRaw) { // We call expectRawStream even if it's P300 with Chunk=1, because out-of-order packets might be waiting await this.expectRawStream(duid, msgIdRaw, photoData.expectedSize || 0); } if (isComplete) { await this.processAndResolvePhoto(photoData, duid, requestKey, protocol); } return true; } // Not a header and not an expected raw stream -> Early Data? this.bufferEarlyDataPacket(duid, payloadBuf); return false; } catch (e: unknown) { this.adapter.rLog("MQTT", duid, "Error", undefined, protocol.toString(), `[Photo] Reassembly error: ${this.adapter.errorMessage(e)}`, "error"); return true; } } private async expectRawStream(duid: string, msgIdRaw: number, totalSize: number): Promise<void> { this.expectedRawStreams.set(duid, { msgIdRaw, totalSize }); // Check for early data packets that arrived before the header const earlyData = this.earlyDataPackets.get(duid); if (earlyData) { clearTimeout(earlyData.timer); this.earlyDataPackets.delete(duid); // Process strictly in order for (const buf of earlyData.buffers) { await this.handlePhotoProtocol301(duid, buf); } } } private initializePhotoRequest(photoId: number, expectedSize: number): PhotoRequestData { return { id: photoId, chunks: {}, expectedSize: expectedSize, lastUpdateTime: Date.now() }; } private parseProtocol301Header(duid: string, payloadBuf: Buffer): { msgIdRaw: number; sequence: number; totalSize: number; headerLength: number; dataSkip: number } | null { if (payloadBuf.length < 26) { this.adapter.rLog("MQTT", duid, "Warn", undefined, "301", `[Photo] Short header ignored (bytes=${payloadBuf.toString("hex")})`, "warn"); return null; } const header = proto301HeaderParser.parse(payloadBuf); // If headerLength is > 26 (e.g. 184), we only skip 26 for sequence 1 to keep RSA, but FULL header for seq > 1 const dataSkip = header.sequence === 1 ? 26 : header.headerLength; return { msgIdRaw: header.msgIdRaw, sequence: header.sequence, totalSize: header.chunkSize, // For P301, chunkSize often is the image size (if seq=1) headerLength: header.headerLength, dataSkip: dataSkip }; } private matchRawStreamContext(duid: string): { msgIdRaw: number; sequence: number; totalSize: number; headerLength: number; dataSkip: number } | null { const expected = this.expectedRawStreams.get(duid); if (!expected) return null; const { msgIdRaw, totalSize } = expected; const requestKey = `${duid}_${msgIdRaw}`; const existing = this.pendingPhotoRequests[requestKey]; // If P300 was already handled, it's chunks[0]. If we find 1 chunk, next is sequence 2. // If P300 was somehow missed, we stay at sequence 2 as a placeholder for the missing header. const sequence = existing ? Object.keys(existing.chunks).length + 1 : 2; return { msgIdRaw, sequence, totalSize, headerLength: 0, dataSkip: 0 }; } private bufferEarlyDataPacket(duid: string, payloadBuf: Buffer): void { let entry = this.earlyDataPackets.get(duid); if (!entry) { entry = { buffers: [], timer: setTimeout(() => { this.earlyDataPackets.delete(duid); }, 2000) // Keep for 2s }; this.earlyDataPackets.set(duid, entry); } entry.buffers.push(payloadBuf); } /** * Shared logic to determine if all chunks for a photo have been received. * Now supports speculative decryption for encrypted photos where totalSize is unknown. */ public async isPhotoComplete(photoData: PhotoRequestData): Promise<boolean> { const keys = Object.keys(photoData.chunks) .map(Number) .sort((a, b) => a - b); const currentSize = keys.reduce((sum, k) => sum + photoData.chunks[k].length, 0); // Case A: Size Math (Most reliable) // expectedSize = totalSize + (headerLength - 26) if (photoData.expectedSize && photoData.expectedSize > 0 && currentSize >= photoData.expectedSize) { return true; } // Case B: Feature Hint (Thumbnail Type 1 is often complete in one packet) const req = this.adapter.pendingRequests.get(photoData.id as any) as any; if (req?.params?.data_filter?.type === 1 && keys.length > 0 && (!photoData.expectedSize || currentSize >= photoData.expectedSize)) { return true; } // Case C: Speculative Detection (fallback) const isCipher1 = req?.params?.security?.cipher_suite === 1; try { const totalBuffer = Buffer.concat(keys.map((k) => photoData.chunks[k])); let checkBuf = totalBuffer; if (isCipher1) { // decryptPhotoPayload will throw if padding is wrong (truncated buffer) checkBuf = cryptoEngine.decryptPhotoPayload(totalBuffer) as any; } // Speculative extraction to find JPEG/PNG boundaries const extracted = await this.extractPhotoData(checkBuf); if (extracted.photo && extracted.photo.length > 0) { const buf = extracted.photo; // JPEG EOF: FF D9 if (buf.length >= 2 && buf[buf.length - 2] === 0xff && buf[buf.length - 1] === 0xd9) { photoData.extracted = extracted; return true; } // PNG EOF: IEND block if (buf.length >= 12 && buf.toString("hex", buf.length - 12).includes("49454e44ae426082")) { photoData.extracted = extracted; return true; } } } catch { // Decryption or decompression failure usually means incomplete data } return false; } /** * Processes a completed photo request: extracts data and resolves the pending request. */ private async processAndResolvePhoto(photoData: PhotoRequestData, duid: string, requestKey: string, protocol: number): Promise<void> { try { let finalPhotoBuf: Buffer; let bbox: any; if (photoData.extracted) { finalPhotoBuf = photoData.extracted.photo; bbox = photoData.extracted.bbox; } else { const sortedKeys = Object.keys(photoData.chunks).map(Number).sort((a, b) => a - b); const totalBuffer = Buffer.concat(sortedKeys.map(k => photoData.chunks[k])); const req = this.adapter.pendingRequests.get(photoData.id) as any; const isCipher1 = req?.params?.security?.cipher_suite === 1; let decryptedBuffer: Buffer = totalBuffer; // Priority: Standard P300 Decryption if (isCipher1) { try { decryptedBuffer = cryptoEngine.decryptPhotoPayload(totalBuffer); } catch (e: unknown) { this.adapter.rLog("MQTT", duid, "Error", undefined, protocol.toString(), `[Photo] Decryption failed: ${this.adapter.errorMessage(e)}`, "error", photoData.id); throw e; } } const extracted = await this.extractPhotoData(decryptedBuffer); finalPhotoBuf = extracted.photo; bbox = extracted.bbox; } if (finalPhotoBuf && finalPhotoBuf.length > 0) { this.adapter.requestsHandler.resolvePendingRequest(photoData.id, { buffer: finalPhotoBuf, bbox }, protocol.toString(), duid, "MQTT"); // Clean up raw stream expectation if complete const expected = this.expectedRawStreams.get(duid); if (expected && expected.msgIdRaw === photoData.id) { this.expectedRawStreams.delete(duid); } } } catch (err: unknown) { const version = await this.adapter.getDeviceProtocolVersion(duid).catch(() => "1.0"); this.adapter.rLog("MQTT", duid, "Error", version, protocol.toString(), `[Photo] Failed to reassemble/extract: ${this.adapter.errorMessage(err)}`, "error", photoData.id); } delete this.pendingPhotoRequests[requestKey]; } /** * Helper: Extracts JPEG/PNG data and Bounding Box from raw photo payload. * Handles GZIP decompression and inner header stripping. */ public async extractPhotoData(rawPayload: Buffer): Promise<{ photo: Buffer; bbox: any | null }> { if (rawPayload.length < 8) throw new Error("Payload too short"); let workingBuf = rawPayload; let bbox = null; if (workingBuf.length > 2 && workingBuf[0] === 0x1f && workingBuf[1] === 0x8b) { try { workingBuf = (await unzipAsync(workingBuf)) as unknown as Buffer; } catch (e: unknown) { throw new Error(`GZIP decompression failed: ${this.adapter.errorMessage(e)}`); } } const startsWithJpeg = workingBuf.length >= 2 && workingBuf[0] === 0xff && workingBuf[1] === 0xd8; const startsWithPng = workingBuf.length >= 4 && workingBuf[0] === 0x89 && workingBuf[1] === 0x50 && workingBuf[2] === 0x4e && workingBuf[3] === 0x47; if (startsWithJpeg || startsWithPng) { return { photo: workingBuf, bbox: null }; } try { const innerHeader = v1PhotoInnerHeaderParser.parse(workingBuf); let strippedData = workingBuf; if (innerHeader.headerLength > 0 && innerHeader.headerLength < workingBuf.length) { strippedData = workingBuf.subarray(innerHeader.headerLength); bbox = this.parseProprietaryHeader(workingBuf, innerHeader.type); } const isJpeg = strippedData.length >= 2 && strippedData[0] === 0xff && strippedData[1] === 0xd8; const isPng = strippedData.length >= 4 && strippedData[0] === 0x89 && strippedData[1] === 0x50 && strippedData[2] === 0x4e && strippedData[3] === 0x47; if (isJpeg || isPng) { return { photo: strippedData, bbox }; } else { const found = this.findImageInBuffer(workingBuf); return { photo: found || strippedData, bbox }; } } catch { const found = this.findImageInBuffer(workingBuf); return { photo: found || workingBuf, bbox: null }; } } private findImageInBuffer(buf: Buffer): Buffer | null { const jpegOffset = buf.indexOf(Buffer.from([0xff, 0xd8])); const pngOffset = buf.indexOf(Buffer.from([0x89, 0x50, 0x4e, 0x47])); if (jpegOffset !== -1 && (pngOffset === -1 || jpegOffset < pngOffset)) { return buf.subarray(jpegOffset); } if (pngOffset !== -1) { return buf.subarray(pngOffset); } return null; } public clearIntervals(): void { this.pendingPhotoRequests = {}; if (this.photoCleanupInterval) { this.adapter.clearInterval(this.photoCleanupInterval); this.photoCleanupInterval = undefined; } } private parseProprietaryHeader(buf: Buffer, type: number): any | null { if (type !== 4 || buf.length < 36) return null; try { const extraHeader = v1PhotoProprietaryHeaderParser.parse(buf.subarray(8, 36)); const isValidWidth = extraHeader.width > 300 && extraHeader.width < 10000; if (isValidWidth) { return { imageWidth: extraHeader.width, imageHeight: extraHeader.height, classId: extraHeader.classId, instanceId: extraHeader.instanceId, x: extraHeader.x1, y: extraHeader.y1, w: extraHeader.x2 - extraHeader.x1, h: extraHeader.y2 - extraHeader.y1 }; } } catch { // Ignore parsing errors } return null; } }