UNPKG

@george.talusan/homebridge-eufy-robovac

Version:
572 lines 26.8 kB
import { MatterStatus } from 'homebridge'; import { BaseMatterAccessory } from './BaseMatterAccessory.js'; import { mapEufyErrorToMatterErrorState, getEufyErrorDescription, getMatterErrorStateName, } from './errorMapping.js'; // Operational state constants const OP_STOPPED = 0; const OP_RUNNING = 1; const OP_PAUSED = 2; const OP_ERROR = 3; const OP_SEEKING_CHARGER = 64; const OP_CHARGING = 65; const OP_DOCKED = 66; // Run mode constants const RUN_IDLE = 0; const RUN_CLEANING = 1; // Clean speed mode constants const CLEAN_SPEED_QUIET = 0; const CLEAN_SPEED_STANDARD = 1; const CLEAN_SPEED_TURBO = 2; const CLEAN_SPEED_MAX = 3; // Identify type: 3 = AudibleBeep (plays a sound to locate the device) const IDENTIFY_TYPE_AUDIBLE_BEEP = 3; export class EufyRobovacMatterAccessory extends BaseMatterAccessory { currentOperationalState; selectedAreaIds; currentCleanSpeed; currentErrorState; roomMap; robovac; constructor(api, log, config, robovac) { const serialNumber = config.deviceId; const roomSwitches = config.roomSwitches ?? []; const roomMap = roomSwitches.map((rs) => ({ name: rs.name, rooms: rs.rooms.split(',').map(Number), })); const hasServiceArea = roomMap.length > 0; const allAreaIds = roomMap.map((_r, i) => i); super(api, log, { UUID: api.matter.uuid.generate(`${config.name}-${config.ip}`), displayName: `${config.name}`, deviceType: api.matter.deviceTypes.RoboticVacuumCleaner, serialNumber, manufacturer: 'Eufy', model: 'RoboVac', firmwareRevision: '1.0.0', hardwareRevision: '1.0.0', clusters: { identify: { identifyTime: 0, identifyType: IDENTIFY_TYPE_AUDIBLE_BEEP, }, powerSource: { status: 0, order: 0, description: 'Battery', batPercentRemaining: Math.max(0, Math.min(200, Math.round(EufyRobovacMatterAccessory.safeBatteryLevel(robovac) * 2))), batChargeLevel: EufyRobovacMatterAccessory.computeChargeLevel(EufyRobovacMatterAccessory.safeBatteryLevel(robovac)), batReplaceability: 1, batChargeState: EufyRobovacMatterAccessory.computeChargeState(robovac), }, rvcRunMode: { supportedModes: [ { label: 'Idle', mode: RUN_IDLE, modeTags: [{ value: 16384 }] }, { label: 'Cleaning', mode: RUN_CLEANING, modeTags: [{ value: 16385 }] }, ], currentMode: RUN_IDLE, }, rvcCleanMode: { supportedModes: [ { label: 'Quiet', mode: CLEAN_SPEED_QUIET, modeTags: [{ value: 2 }, { value: 16385 }] }, { label: 'Standard', mode: CLEAN_SPEED_STANDARD, modeTags: [{ value: 16385 }] }, { label: 'Turbo', mode: CLEAN_SPEED_TURBO, modeTags: [{ value: 1 }, { value: 16385 }] }, { label: 'Max', mode: CLEAN_SPEED_MAX, modeTags: [{ value: 16384 }, { value: 16385 }] }, ], currentMode: CLEAN_SPEED_STANDARD, }, rvcOperationalState: { operationalStateList: [ { operationalStateId: OP_STOPPED }, { operationalStateId: OP_RUNNING }, { operationalStateId: OP_PAUSED }, { operationalStateId: OP_ERROR }, { operationalStateId: OP_SEEKING_CHARGER }, { operationalStateId: OP_CHARGING }, { operationalStateId: OP_DOCKED }, ], operationalState: OP_DOCKED, }, ...(hasServiceArea ? { serviceArea: { supportedMaps: [ { mapId: 0, name: 'Home' }, ], supportedAreas: roomMap.map((room, index) => ({ areaId: index, mapId: 0, areaInfo: { locationInfo: { locationName: room.name, floorNumber: 0, areaType: null, }, landmarkInfo: null, }, })), selectedAreas: [...allAreaIds], }, } : {}), }, handlers: { identify: { identify: async (request) => this.handleIdentify(request), }, rvcRunMode: { changeToMode: async (request) => this.handleChangeRunMode(request), }, rvcCleanMode: { changeToMode: async (request) => { this.logInfo(`clean mode change requested: ${request.newMode}`); this.ensureConnected(); const { newMode } = request; try { switch (newMode) { case CLEAN_SPEED_QUIET: this.logDebug('setting clean speed to Quiet'); await this.robovac.setCleanSpeedQuiet(); break; case CLEAN_SPEED_STANDARD: this.logDebug('setting clean speed to Standard'); await this.robovac.setCleanSpeedStandard(); break; case CLEAN_SPEED_TURBO: this.logDebug('setting clean speed to Turbo'); await this.robovac.setCleanSpeedTurbo(); break; case CLEAN_SPEED_MAX: this.logDebug('setting clean speed to Max'); await this.robovac.setCleanSpeedMax(); break; default: this.logWarn(`unknown clean speed mode: ${newMode}`); } this.currentCleanSpeed = newMode; await this.updateCleanMode(newMode); } catch (error) { this.logError('Failed to set clean speed:', error); throw error; } }, }, rvcOperationalState: { pause: async () => this.handlePause(), resume: async () => this.handleResume(), goHome: async () => this.handleGoHome(), }, ...(hasServiceArea ? { serviceArea: { selectAreas: async (request) => this.handleSelectAreas(request), skipArea: async (request) => this.handleSkipArea(request), }, } : {}), }, }); this.robovac = robovac; this.roomMap = roomMap; this.selectedAreaIds = [...allAreaIds]; this.currentOperationalState = OP_DOCKED; this.currentCleanSpeed = CLEAN_SPEED_STANDARD; this.currentErrorState = 0; // NoError this.setupEventListeners(); this.logInfo('initialized and ready.'); } // ─── Handlers ────────────────────────────────────────────────────── async handleIdentify(request) { this.logInfo(`identify requested (identifyTime=${request.identifyTime}s) — playing sound to locate`); this.ensureConnected(); try { await this.robovac.locate(true); } catch (error) { this.logError('Failed to locate RoboVac:', error); throw error; } } async handleChangeRunMode(request) { this.logInfo(`run mode change requested: ${request.newMode === RUN_IDLE ? 'Idle' : 'Cleaning'} (${request.newMode})`); this.ensureConnected(); const { newMode } = request; if (newMode === RUN_CLEANING) { if (this.selectedAreaIds.length > 0 && this.roomMap.length > 0) { const rooms = this.selectedAreaIds.flatMap(id => this.roomMap[id]?.rooms ?? []); this.logDebug(`cleaning rooms: ${rooms.join(', ')}`); await this.robovac.cleanRooms(rooms); } else { this.logDebug('starting full clean'); await this.robovac.clean(); } await this.updateOperationalState(OP_RUNNING); } else if (newMode === RUN_IDLE) { this.logDebug('pausing and returning to dock'); await this.robovac.pause(); if (this.supportsHome()) { await this.robovac.goHome(true); await this.updateOperationalState(OP_SEEKING_CHARGER); } else { await this.updateOperationalState(OP_STOPPED); } } } async handlePause() { this.logInfo('pause requested'); this.ensureConnected(); const invalidStates = [OP_CHARGING, OP_DOCKED]; if (invalidStates.includes(this.currentOperationalState)) { this.logWarn(`cannot pause in state ${this.currentOperationalState}`); throw new MatterStatus.InvalidInState(`Cannot pause while in state ${this.currentOperationalState}`); } await this.robovac.pause(); await this.updateOperationalState(OP_PAUSED); } async handleResume() { this.logInfo('resume requested'); this.ensureConnected(); const invalidStates = [OP_SEEKING_CHARGER, OP_CHARGING, OP_DOCKED]; if (invalidStates.includes(this.currentOperationalState)) { this.logWarn(`cannot resume in state ${this.currentOperationalState}`); throw new MatterStatus.InvalidInState(`Cannot resume while in state ${this.currentOperationalState}`); } await this.robovac.resume(); await this.updateRunMode(RUN_CLEANING); await this.updateOperationalState(OP_RUNNING); } async handleGoHome() { this.logInfo('go home requested'); this.ensureConnected(); if (this.currentOperationalState === OP_DOCKED || this.currentOperationalState === OP_CHARGING) { this.logWarn('go home requested but already docked/charging'); throw new MatterStatus.InvalidInState('Already docked'); } await this.robovac.pause(); await this.robovac.goHome(true); await this.updateRunMode(RUN_IDLE); await this.updateOperationalState(OP_SEEKING_CHARGER); } async handleSelectAreas(request) { this.logInfo(`select areas requested: [${request.newAreas.join(', ')}]`); const { newAreas } = request; for (const areaId of newAreas) { if (areaId < 0 || areaId >= this.roomMap.length) { this.logWarn(`area ID ${areaId} not found`); throw new MatterStatus.NotFound(`Area ID ${areaId} not found`); } } this.selectedAreaIds = newAreas.length > 0 ? [...newAreas] : this.roomMap.map((_r, i) => i); await this.updateState('serviceArea', { selectedAreas: [...this.selectedAreaIds] }); const areaNames = this.selectedAreaIds.map(id => this.roomMap[id]?.name ?? `Area ${id}`); this.logInfo(`selected areas: ${areaNames.join(', ')}`); } async handleSkipArea(request) { this.logInfo(`skip area requested: ${request.skippedArea}`); const { skippedArea } = request; if (skippedArea < 0 || skippedArea >= this.roomMap.length) { this.logWarn(`area ID ${skippedArea} not found`); throw new MatterStatus.NotFound(`Area ID ${skippedArea} not found`); } this.selectedAreaIds = this.selectedAreaIds.filter(id => id !== skippedArea); await this.updateState('serviceArea', { selectedAreas: [...this.selectedAreaIds] }); this.logInfo(`skipped area ${skippedArea}, remaining: [${this.selectedAreaIds.join(', ')}]`); } // ─── State Sync ──────────────────────────────────────────────────── setMatterReady() { super.setMatterReady(); this.logDebug('Matter ready — syncing initial state'); this.syncState(); } setupEventListeners() { this.robovac.on('tuya.data', () => { this.logDebug('tuya.data event received — syncing state'); this.syncState(); }); this.robovac.on('event', (event) => { this.logDebug(`device event: ${event.command} = ${JSON.stringify(event.value)}`); if (event.command === 'battery') { this.updateBatteryState().catch(e => this.logError('Failed to update battery state:', e)); } else if (event.command === 'activity') { const activity = event.value; if (activity === 'Sleeping' || activity === 'completed') { const docked = EufyRobovacMatterAccessory.safeDockedState(this.robovac); const level = EufyRobovacMatterAccessory.safeBatteryLevel(this.robovac); const state = docked && level < 100 ? OP_CHARGING : OP_DOCKED; this.logInfo(`activity changed to '${activity}' — transitioning to ${state === OP_CHARGING ? 'Charging' : 'Docked'}`); this.updateOperationalState(state).catch(e => this.logError('Failed to update state:', e)); this.updateRunMode(RUN_IDLE).catch(e => this.logError('Failed to update state:', e)); } else if (activity === 'Charging') { this.logInfo('activity changed to Charging — transitioning to Charging state'); this.updateOperationalState(OP_CHARGING).catch(e => this.logError('Failed to update state:', e)); } else if (activity === 'Recharge') { this.logInfo('activity changed to Recharge — transitioning to SeekingCharger state'); this.updateOperationalState(OP_SEEKING_CHARGER).catch(e => this.logError('Failed to update state:', e)); this.updateRunMode(RUN_IDLE).catch(e => this.logError('Failed to update state:', e)); } else { this.logDebug(`activity changed to '${activity}' — running full state sync`); this.syncState(); } } else if (event.command === 'goHome') { if (event.value === false) { // goHome flag cleared — robot has stopped seeking charger, re-evaluate actual state this.logInfo('goHome flag cleared — syncing state'); this.syncState(); } } else if (event.command === 'coverage') { if (event.value > 0 && this.currentOperationalState !== OP_RUNNING) { this.logInfo(`coverage event received (${event.value}) — inferring Running state`); this.updateOperationalState(OP_RUNNING).catch(e => this.logError('Failed to update state:', e)); this.updateRunMode(RUN_CLEANING).catch(e => this.logError('Failed to update state:', e)); } } else if (event.command === 'playPause') { if (event.value === false) { this.updateOperationalState(OP_PAUSED).catch(e => this.logError('Failed to update state:', e)); } else { this.updateOperationalState(OP_RUNNING).catch(e => this.logError('Failed to update state:', e)); this.updateRunMode(RUN_CLEANING).catch(e => this.logError('Failed to update state:', e)); } } else if (event.command === 'error') { const errorValue = event.value; if (errorValue && errorValue !== 'no error') { this.logWarn(`device error reported: ${errorValue}`); this.updateErrorState(errorValue).catch(e => this.logError('Failed to update error state:', e)); this.updateOperationalState(OP_ERROR).catch(e => this.logError('Failed to update operational state:', e)); } else { this.clearErrorState().catch(e => this.logError('Failed to clear error state:', e)); } } else if (event.command === 'cleanSpeed') { const speedMap = { 'Quiet': CLEAN_SPEED_QUIET, 'Standard': CLEAN_SPEED_STANDARD, 'Turbo': CLEAN_SPEED_TURBO, 'Max': CLEAN_SPEED_MAX, }; const cleanSpeed = speedMap[event.value]; if (cleanSpeed !== undefined) { this.logDebug(`clean speed changed to ${event.value}`); this.updateCleanMode(cleanSpeed).catch(e => this.logError('Failed to update clean mode:', e)); } else { this.logWarn(`unknown clean speed: ${event.value}`); } } }); this.robovac.on('tuya.disconnected', () => { this.logWarn('RoboVac disconnected'); }); this.robovac.on('alert', (alert) => { this.logInfo(`consumable maintenance alert: ${JSON.stringify(alert)}`); if (alert.consumable) { this.logWarn(`[MAINTENANCE WARNING] ${alert.consumable}: ${alert.duration}`); } }); } syncState() { if (!this.robovac.connected) { this.logDebug('syncState skipped — not connected'); return; } this.syncOperationalState(); this.syncCleanSpeed(); this.updateBatteryState().catch(e => this.logError('Failed to sync battery state:', e)); } syncOperationalState() { if (!this.robovac.connected) { return; } try { const error = this.robovac.error(); if (error && error !== 'no error') { this.logWarn(`device error: ${error}`); this.updateErrorState(error).catch(e => this.logError('Failed to update error state:', e)); this.updateOperationalState(OP_ERROR); return; } this.clearErrorState().catch(e => this.logError('Failed to clear error state:', e)); const activity = this.robovac.activity(); this.logDebug(`syncing operational state — activity: ${activity}`); if (activity === 'Sleeping' || activity === 'completed') { const docked = EufyRobovacMatterAccessory.safeDockedState(this.robovac); const level = EufyRobovacMatterAccessory.safeBatteryLevel(this.robovac); const state = docked && level < 100 ? OP_CHARGING : OP_DOCKED; this.updateOperationalState(state); this.updateRunMode(RUN_IDLE); return; } if (activity === 'Charging') { this.updateOperationalState(OP_CHARGING); return; } if (activity === 'Recharge') { this.updateOperationalState(OP_SEEKING_CHARGER); this.updateRunMode(RUN_IDLE); return; } if (this.robovac.goingHome()) { this.updateOperationalState(OP_SEEKING_CHARGER); return; } } catch (error) { this.logError('Failed to sync operational state:', error); } } syncCleanSpeed() { if (!this.robovac.connected) { return; } try { const cleanSpeed = EufyRobovacMatterAccessory.safeCleanSpeed(this.robovac); if (cleanSpeed !== this.currentCleanSpeed) { this.logDebug(`syncing clean speed: ${cleanSpeed}`); this.updateCleanMode(cleanSpeed).catch(e => this.logError('Failed to sync clean speed:', e)); } } catch (error) { this.logError('Failed to sync clean speed:', error); } } // ─── State Update Helpers ────────────────────────────────────────── async updateOperationalState(state) { this.logDebug(`updating operational state: ${state}`); this.currentOperationalState = state; await this.updateState('rvcOperationalState', { operationalState: state }); const batChargeState = state === OP_CHARGING ? 1 : 3; await this.updateState('powerSource', { batChargeState }); } async updateRunMode(mode) { this.logDebug(`updating run mode: ${mode === RUN_IDLE ? 'Idle' : 'Cleaning'} (${mode})`); await this.updateState('rvcRunMode', { currentMode: mode }); } async updateCleanMode(mode) { const modeLabel = ['Quiet', 'Standard', 'Turbo', 'Max'][mode] ?? `Unknown (${mode})`; this.logDebug(`updating clean mode: ${modeLabel} (${mode})`); this.currentCleanSpeed = mode; await this.updateState('rvcCleanMode', { currentMode: mode }); } async updateErrorState(eufyErrorCode) { const matterErrorStateId = mapEufyErrorToMatterErrorState(eufyErrorCode); const matterErrorStateName = getMatterErrorStateName(matterErrorStateId); const eufyErrorDescription = getEufyErrorDescription(eufyErrorCode); this.logDebug(`mapping eufy error '${eufyErrorCode}' (${eufyErrorDescription}) to Matter ErrorState ${matterErrorStateId} (${matterErrorStateName})`); this.currentErrorState = matterErrorStateId; // Create ErrorStateStruct with mapped error state // Note: errorStateLabel is only valid for manufacturer-specific error codes (128-191) // Since we use only standard error states (0-78), we omit errorStateLabel const operationalError = { errorStateId: matterErrorStateId, errorStateDetails: eufyErrorDescription, }; this.logDebug(`updating operational error: ${JSON.stringify(operationalError)}`); await this.updateState('rvcOperationalState', { operationalError }); } async clearErrorState() { this.logDebug('clearing error state'); this.currentErrorState = 0; const operationalError = { errorStateId: 0, // NoError errorStateDetails: 'No error', }; await this.updateState('rvcOperationalState', { operationalError }); } async updateBatteryState() { const level = EufyRobovacMatterAccessory.safeBatteryLevel(this.robovac); const batPercentRemaining = Math.max(0, Math.min(200, Math.round(level * 2))); const batChargeLevel = EufyRobovacMatterAccessory.computeChargeLevel(level); this.logDebug(`updating battery: ${level}% (batPercentRemaining=${batPercentRemaining}, batChargeLevel=${batChargeLevel})`); await this.updateState('powerSource', { batPercentRemaining, batChargeLevel }); } getOperationalState() { return this.currentOperationalState; } // ─── Utility ─────────────────────────────────────────────────────── ensureConnected() { if (!this.robovac.connected) { this.logWarn('command rejected — RoboVac is not connected'); throw new MatterStatus.Failure('RoboVac is not connected'); } } supportsHome() { try { this.robovac.goingHome(); return true; } catch { return false; } } static safeBatteryLevel(robovac) { try { return robovac.batteryLevel(); } catch { return 100; } } static safeDockedState(robovac) { try { return robovac.docked(); } catch { return false; } } static safeCleanSpeed(robovac) { try { const speedMap = { 'Quiet': CLEAN_SPEED_QUIET, 'Standard': CLEAN_SPEED_STANDARD, 'Turbo': CLEAN_SPEED_TURBO, 'Max': CLEAN_SPEED_MAX, }; // dps['102'] contains the clean speed value string const cleanSpeedValue = robovac.dps?.['102']; if (cleanSpeedValue === undefined) { return CLEAN_SPEED_STANDARD; } const mappedSpeed = speedMap[cleanSpeedValue]; return mappedSpeed !== undefined ? mappedSpeed : CLEAN_SPEED_STANDARD; } catch { return CLEAN_SPEED_STANDARD; } } static computeChargeLevel(percentage) { if (percentage < 20) { return 2; // Critical } if (percentage < 40) { return 1; // Warning } return 0; // Ok } static computeChargeState(robovac) { try { const activity = robovac.activity(); if (activity === 'Charging') { return 1; // IsCharging } if ((activity === 'Sleeping' || activity === 'completed') && this.safeDockedState(robovac) && this.safeBatteryLevel(robovac) < 100) { return 1; // IsCharging } return 3; // IsNotCharging } catch { return 0; // Unknown } } } //# sourceMappingURL=EufyRobovacMatterAccessory.js.map