UNPKG

xmihome-devices

Version:

Device definitions and specifications for the node-xmihome ecosystem.

385 lines (350 loc) 14 kB
import Device from 'xmihome/device.js'; import zlib from 'zlib'; /** @import { Property, Action } from 'xmihome/device.js' */ /** * Класс для управления пылесосом ROIDMI EVE. * @extends Device */ export default class RoidmiVacuum extends Device { /** @type {string} */ static name = 'ROIDMI EVE'; /** @type {string[]} */ static models = [ 'roidmi.vacuum.v60' ]; /** * Возможные значения свойства `status` (статус работы). * @type {object} */ static STATUS = { 1: 'Dormant', 2: 'Idle', 3: 'Paused', 4: 'Sweeping', 5: 'Go Charging', 6: 'Charging', 7: 'Error', 8: 'Remote Control', 9: 'Full Charge', 10: 'Shutdown', 11: 'Find Charger Paused' }; /** * Возможные значения свойства `fault` (коды ошибок). * @type {object} */ static FAULT = { 0: 'No Faults', 1: 'Low Battery, returning to charger', 2: 'Low Battery and Powering Off', 3: 'Wheel trapped', 4: 'Collision sensor error', 5: 'Device tilted', 6: 'Lidar blocked', 7: 'Front collision sensor dirty', 8: 'Side wall sensor dirty', 9: 'Main brush trapped', 10: 'Side brush trapped', 11: 'Fan speed error', 12: 'Lidar cover trapped', 13: 'Dustbin full, please clean', 14: 'Dustbin removed', 15: 'Dustbin full (while removed)', 16: 'Device trapped', 17: 'Device lifted, place on ground to start', 18: 'Water tank removed', 19: 'Insufficient water', 20: 'Designated area unreachable', 21: 'Cannot start from forbidden zone', 22: 'Cliff sensor detected, move away to start', 23: 'Water pump current error', 24: 'Failed to return to charger', 25: 'Low power clean, water pump open circuit' }; /** * Возможные значения свойства `mode` (мощность всасывания). * @type {string[]} */ static MODE = ['Off', 'Silent', 'Basic', 'Strong', 'Full Speed']; /** * Возможные значения свойства `sweep_type` (тип уборки). * @type {string[]} */ static SWEEP_TYPE = ['Sweep', 'Mop', 'Mop and Sweep']; /** * Возможные значения свойства `charging_status`. * @type {object} */ static CHARGING_STATUS = { 1: 'Charging', 2: 'Not charging', 3: 'Not chargeable' }; /** * Возможные значения свойства `water_level` (уровень подачи воды). * @type {string[]} */ static WATER_LEVEL = ['Off', 'Low', 'Medium', 'High', 'Maximum']; /** * @type {({ * status: Property, * fault: Property, * mode: Property, * sweep_type: Property, * battery_level: Property, * charging_status: Property, * water_level: Property * }) & { [x: string]: Property }} * @property {Property} status - Статус работы. * @property {Property} fault - Код ошибки устройства. * @property {Property} mode - Уровень мощности всасывания. * @property {Property} sweep_type - Тип уборки * @property {Property} battery_level - Уровень заряда батареи в процентах. * @property {Property} charging_status - Статус процесса зарядки. * @property {Property} water_level - Уровень подачи воды. */ properties = { 'status': { siid: 2, piid: 1, format: 'uint8', access: ['read', 'notify'] }, 'fault': { siid: 2, piid: 2, format: 'uint8', access: ['read', 'notify'] }, 'mode': { siid: 2, piid: 4, format: 'uint8', access: ['read', 'write', 'notify'] }, 'sweep_type': { siid: 2, piid: 8, format: 'uint8', access: ['read', 'notify'] }, 'battery_level': { siid: 3, piid: 1, format: 'uint8', access: ['read', 'notify'] }, 'charging_status': { siid: 3, piid: 2, format: 'uint8', access: ['read', 'notify'] }, 'water_level': { siid: 8, piid: 11, format: 'uint8', access: ['read', 'write', 'notify'] } }; /** * Действия, которые можно выполнять с устройством. * @type {({ * start_sweep: Action, * stop_sweep: Action, * start_charge: Action * }) & { [x: string]: Action }} * @property {Action} start_sweep - Начать уборку. * @property {Action} stop_sweep - Остановить/приостановить уборку. * @property {Action} start_charge - Отправить на базу для зарядки. */ actions = { 'start_sweep': { siid: 2, aiid: 1 }, 'stop_sweep': { siid: 2, aiid: 2 }, 'start_charge': { siid: 3, aiid: 1 }, 'start_room_sweep': { siid: 14, aiid: 1 } }; /** * @type {Buffer|null} Кэш бинарных данных пикселей карты. */ #pixelData = null; /** * @type {object|null} Кэш метаданных карты. */ #metaData = null; /** * @type {number} Временная метка сохранения кэша в миллисекундах. */ #cacheTimestamp = 0; /** * Получает временную подписанную ссылку для скачивания файла, связанного с устройством. * @param {string} obj_name Имя объекта/файла в облачном хранилище. * @returns {Promise<string>} URL для скачивания файла. */ async #getFileUrl(obj_name) { this.client.log('debug', `Requesting file URL for object: ${obj_name}`); const response = await this.client.miot.request('/v2/home/get_interim_file_url', { obj_name, did: this.config.id }); if ((response.code !== 0) || !response.result?.url) throw new Error(`Failed to get file URL: ${response.message || 'Unknown error'}`); this.client.log('info', `Successfully got file URL for: ${obj_name}`); return response.result.url; }; /** * Загружает и парсит файл карты. Использует и проверяет кэш на основе временной метки. * @returns {Promise<{pixelData: Buffer, metaData: object}>} */ async #loadMapData() { if (this.#pixelData && this.#metaData) { if ((Date.now() - this.#cacheTimestamp) < 15_000) return { pixelData: this.#pixelData, metaData: this.#metaData }; } if (this.connectionType !== 'cloud') throw new Error('Getting file URLs is only supported for cloud connections.'); if (!this.client.miot.credentials.serviceToken) await this.client.miot.login(); this.client.log('info', 'Requesting map data file (obj_name ending in /0)...'); const url = await this.#getFileUrl(`${this.client.miot.credentials.userId}/${this.config.id}/0`); const response = await fetch(url); if (!response.ok) throw new Error(`Failed to download map file: ${response.statusText}`); const buffer = await response.arrayBuffer(); this.client.log('info', `Map data downloaded successfully (${buffer.byteLength} bytes).`); const decompressed = zlib.gunzipSync(Buffer.from(buffer)); this.client.log('debug', `Map buffer decompressed. Total size: ${decompressed.length} bytes.`); const firstBrace = decompressed.indexOf('{'); const lastBrace = decompressed.lastIndexOf('}'); if ((firstBrace === -1) || (lastBrace === -1) || (lastBrace < firstBrace)) throw new Error('Could not find a valid JSON object within the decompressed map data.'); const jsonString = decompressed.toString('utf8', firstBrace, lastBrace + 1); this.#metaData = JSON.parse(jsonString); const expectedPixelDataSize = this.#metaData.width * this.#metaData.height; const preJsonBlock = decompressed.subarray(0, firstBrace); const headerSize = preJsonBlock.length - expectedPixelDataSize; this.client.log('debug', `Autodetected map header size: ${headerSize} bytes.`); if (headerSize < 0) throw new Error("Mismatch in map data size. Decompressed data is smaller than expected."); this.#pixelData = preJsonBlock.subarray(headerSize); this.#cacheTimestamp = Date.now(); return { pixelData: this.#pixelData, metaData: this.#metaData }; }; /** * Преобразует мировые координаты (в мм) в пиксельные координаты SVG. * @param {number} worldX_mm - Координата X в мм. * @param {number} worldY_mm - Координата Y в мм. * @param {object} metaData - Метаданные карты. * @returns {{x: number, y: number}} - Пиксельные координаты. */ #transformToPixels(worldX_mm, worldY_mm, metaData) { const { x_min, y_min, resolution, height } = metaData; return { x: ((worldX_mm / 1_000) - x_min) / resolution, y: height - (((worldY_mm / 1_000) - y_min) / resolution) }; }; /** * Получает список комнат из метаданных карты. * @returns {Promise<{mapId: number, segments: {id: number, name: string}[]}>} */ async getRooms() { const segments = []; this.client.log('info', 'Getting rooms from parsed map file...'); const { metaData } = await this.#loadMapData(); this.client.log('debug', 'Parsed map metadata:', metaData); const mapId = metaData.mapId; const roomList = metaData.autoAreaValue; if (Array.isArray(roomList)) { roomList.forEach(room => segments.push({ id: parseInt(room.id, 10), name: room.name })); this.client.log('info', `Successfully parsed ${segments.length} rooms.`); } else this.client.log('warn', 'Found JSON, but "autoAreaValue" is not a valid array.'); return { mapId, segments }; }; /** * Генерирует SVG-изображение карты. * @returns {Promise<string>} Строка, содержащая SVG-разметку. */ async getMapImage() { const { pixelData, metaData } = await this.#loadMapData(); const { width, height } = metaData; const assignedRoomColors = {}; let nextColorIndex = 0; const PALETTE = ['#00BCD4', '#4CAF50', '#FFC107', '#9575CD', '#42A5F5', '#FF7043', '#EC407A', '#26A69A']; const COLORS = { WALL: '#424242', SEEN_AREA: '#DCDCDC', UNKNOWN: '#EEEEEE', PATH: 'rgba(255, 255, 255, 0.8)', NO_GO_ZONE_FILL: 'rgba(239, 83, 80, 0.7)', CHARGER: '#616161', ROBOT: '#616161', TEXT: '#000000' }; const svgParts = []; svgParts.push(`<svg width="100%" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="background-color: ${COLORS.UNKNOWN};">`); const mapLayer = []; const roomCenters = {}; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const svgY = height - 1 - y; const byteIndex = y * width + x; if (byteIndex >= pixelData.length) continue; const pixelType = pixelData[byteIndex]; let color; if (pixelType === 0xff) color = COLORS.SEEN_AREA; else if (pixelType === 0x00) color = COLORS.WALL; else if (pixelType > 0 && pixelType < 0x7f) { if (!assignedRoomColors[pixelType]) { assignedRoomColors[pixelType] = PALETTE[nextColorIndex % PALETTE.length]; nextColorIndex++; } color = assignedRoomColors[pixelType]; if (!roomCenters[pixelType]) roomCenters[pixelType] = { x_sum: 0, y_sum: 0, count: 0 }; roomCenters[pixelType].x_sum += x; roomCenters[pixelType].y_sum += svgY; roomCenters[pixelType].count++; } if (color) mapLayer.push(`<rect x="${x}" y="${svgY}" width="1" height="1" fill="${color}" />`); } } svgParts.push(`<g id="map-layer">${mapLayer.join('')}</g>`); if (metaData.posArray) { const pathPoints = JSON.parse(metaData.posArray); if (pathPoints && (pathPoints.length > 0)) { const pixelPathParts = []; for (const point of pathPoints) { const { x, y } = this.#transformToPixels(point[0], point[1], metaData); pixelPathParts.push(`${x},${y}`); } svgParts.push(`<polyline points="${pixelPathParts.join(' ')}" fill="none" stroke="${COLORS.PATH}" stroke-width="0.8" stroke-opacity="0.8" />`); } } if (metaData.area && (metaData.area.length > 0)) { const forbiddenLayer = []; for (const area of metaData.area) { const points = []; for (const vertex of area.vertexs) { const { x, y } = this.#transformToPixels(vertex[0], vertex[1], metaData); points.push(`${x},${y}`); } forbiddenLayer.push(`<polygon points="${points.join(' ')}" fill="${COLORS.NO_GO_ZONE_FILL}" />`); } svgParts.push(`<g id="forbidden-layer">${forbiddenLayer.join('')}</g>`); } const iconsLayer = []; if (metaData.chargeHandlePos) { const { x, y } = this.#transformToPixels(metaData.chargeHandlePos[0], metaData.chargeHandlePos[1], metaData); iconsLayer.push(`<g id="charger"> <circle cx="${x}" cy="${y}" r="3" fill="${COLORS.CHARGER}" stroke="${COLORS.WALL}" stroke-width="0.8"/> </g>`); } if ((metaData.posX !== undefined) && (metaData.posY !== undefined)) { const { x, y } = this.#transformToPixels(metaData.posX, metaData.posY, metaData); const angle = (metaData.posPhi / 10) + 90; iconsLayer.push(`<g id="robot" transform="translate(${x}, ${y}) rotate(${angle})"> <circle cx="0" cy="0" r="2.5" fill="${COLORS.ROBOT}" opacity="0.8"/> <path d="M 0 -2 L 1.5 1 L -1.5 1 Z" fill="${COLORS.WALL}"/> </g>`); } svgParts.push(`<g id="icons-layer">${iconsLayer.join('')}</g>`); if (metaData.autoAreaValue) { const textLayer = []; for (const room of metaData.autoAreaValue) { const centerInfo = roomCenters[room.id]; if (centerInfo) { const centerX = centerInfo.x_sum / centerInfo.count; const centerY = centerInfo.y_sum / centerInfo.count; textLayer.push(`<text x="${centerX}" y="${centerY}" fill="${COLORS.TEXT}" font-size="5" text-anchor="middle" dominant-baseline="middle" style="paint-order: stroke; fill: ${COLORS.TEXT}; stroke: #FFF; stroke-width: 0.5px; stroke-linejoin: round;">${room.name}</text>`); } } svgParts.push(`<g id="text-layer">${textLayer.join('')}</g>`); } svgParts.push('</svg>'); return svgParts.join(''); }; };