UNPKG

@homebridge-plugins/homebridge-roomba

Version:
590 lines 22.9 kB
import dorita980 from 'dorita980'; /** * How long to wait to connect to Roomba. */ const CONNECT_TIMEOUT_MILLIS = 60_000; /** * How long after Roomba has been active should we continue frequently polling? */ const AFTER_ACTIVE_MILLIS = 120_000; /** * How long will we wait for the Roomba to send status before giving up? */ const STATUS_TIMEOUT_MILLIS = 60_000; /** * Coalesce multiple refreshState requests when they're less than this many millis apart. */ const REFRESH_STATE_COALESCE_MILLIS = 10_000; const ROBOT_CIPHERS = ['AES128-SHA256', 'TLS_AES_256_GCM_SHA384']; /** * Matter RVC operational state IDs */ const RVC_STATE = { STOPPED: 0, RUNNING: 1, PAUSED: 2, ERROR: 3, SEEKING_CHARGER: 64, CHARGING: 65, DOCKED: 66, }; /** * Matter RVC run mode IDs */ const RVC_RUN_MODE = { IDLE: 0, CLEANING: 1, }; /** * Represents a Roomba device as a Homebridge Matter RoboticVacuumCleaner accessory. * * This class manages the connection to the physical Roomba device, translates * Roomba states to Matter clusters, and handles Matter commands (start, stop, pause, * resume, dock). */ export class RoboticVacuumCleaner { _api; _log; _device; _blid; _robotpwd; _ipaddress; _cleanBehaviour; _mission; _stopBehaviour; _idlePollIntervalMillis; _cachedStatus = { timestamp: 0 }; _lastRefreshState = 0; _roombaLastActiveTimestamp; _pollTimeout; _currentRoombaPromise; _currentCipherIndex = 0; _started = false; /** * The Matter accessory UUID (derived from device blid) */ UUID; /** * The Matter accessory display name */ displayName; constructor(api, log, device, config, version) { this._api = api; this._log = log; this._device = device; this._blid = device.blid; this._robotpwd = device.password; this._ipaddress = device.ipaddress ?? device.ip; this._cleanBehaviour = device.cleanBehaviour ?? 'everywhere'; this._mission = device.mission ?? { pmap_id: 'local' }; this._stopBehaviour = device.stopBehaviour ?? 'home'; this._idlePollIntervalMillis = device.idleWatchInterval ? device.idleWatchInterval * 60_000 : config.idleWatchInterval ? config.idleWatchInterval * 60_000 : 900_000; this.UUID = api.matter?.uuid?.generate(`roomba-${device.blid}`) ?? `roomba-${device.blid}`; this.displayName = device.name; this._log.debug(`[Matter/${this.displayName}] Initialized Matter accessory, UUID: ${this.UUID}`); } /** * Returns a plain Matter accessory object suitable for registration with Homebridge. */ toAccessory() { const matterApi = this._api.matter; return { UUID: this.UUID, displayName: this.displayName, deviceType: matterApi?.deviceTypes?.RoboticVacuumCleaner, serialNumber: this._getSerialNum(this._device), manufacturer: 'iRobot', model: this._device.model ?? 'Roomba', firmwareRevision: this._device.softwareVer ?? '0.0.0', hardwareRevision: '', context: { blid: this._blid, model: this._device.model, }, clusters: { powerSource: { status: 0, // Active order: 0, description: 'Battery', batPercentRemaining: 200, // 200 = 100% (0.5% increments) batChargeLevel: 0, // 0 = Ok batReplaceability: 1, // Not replaceable }, rvcRunMode: { supportedModes: [ { label: 'Idle', mode: RVC_RUN_MODE.IDLE, modeTags: [{ value: 16384 }] }, { label: 'Cleaning', mode: RVC_RUN_MODE.CLEANING, modeTags: [{ value: 16385 }] }, ], currentMode: RVC_RUN_MODE.IDLE, }, rvcCleanMode: { supportedModes: [ { label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] }, ], currentMode: 0, }, rvcOperationalState: { operationalStateList: [ { operationalStateId: RVC_STATE.STOPPED }, { operationalStateId: RVC_STATE.RUNNING }, { operationalStateId: RVC_STATE.PAUSED }, { operationalStateId: RVC_STATE.ERROR }, { operationalStateId: RVC_STATE.SEEKING_CHARGER }, { operationalStateId: RVC_STATE.CHARGING }, { operationalStateId: RVC_STATE.DOCKED }, ], operationalState: RVC_STATE.DOCKED, }, }, handlers: { rvcRunMode: { changeToMode: async (request) => this._handleChangeRunMode(request), }, rvcOperationalState: { pause: async () => this._handlePause(), resume: async () => this._handleResume(), goHome: async () => this._handleGoHome(), }, }, }; } /** * Start polling Roomba's status and pushing updates to Matter. */ startPolling() { if (this._started) { return; } this._started = true; this._schedulePoll(false); } /** * Stop polling Roomba's status. */ stopPolling() { this._started = false; if (this._pollTimeout) { clearTimeout(this._pollTimeout); this._pollTimeout = undefined; } } // --------------------------------------------------------------------------- // Private: handlers // --------------------------------------------------------------------------- async _handleChangeRunMode(request) { const mode = request?.newMode ?? request?.mode ?? -1; this._log.debug(`[Matter/${this.displayName}] changeToMode: ${mode}`); if (mode === RVC_RUN_MODE.CLEANING) { // Start / resume cleaning await this._startCleaning(); } else if (mode === RVC_RUN_MODE.IDLE) { // Stop / dock await this._stopAndDock(); } else { return { status: 1 }; // Unsupported mode } return { status: 0 }; } async _handlePause() { this._log.debug(`[Matter/${this.displayName}] pause`); return new Promise((resolve) => { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.warn(`[Matter/${this.displayName}] Failed to pause: ${error?.message ?? 'Unknown'}`); resolve({ errorStateId: RVC_STATE.ERROR }); return; } try { await roomba.pause(); this._log.debug(`[Matter/${this.displayName}] Paused`); this._schedulePoll(true); resolve({ errorStateId: 0 }); } catch (err) { this._log.warn(`[Matter/${this.displayName}] Pause failed: ${err.message}`); resolve({ errorStateId: RVC_STATE.ERROR }); } }); }); } async _handleResume() { this._log.debug(`[Matter/${this.displayName}] resume`); return new Promise((resolve) => { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.warn(`[Matter/${this.displayName}] Failed to resume: ${error?.message ?? 'Unknown'}`); resolve({ errorStateId: RVC_STATE.ERROR }); return; } try { await roomba.resume(); this._log.debug(`[Matter/${this.displayName}] Resumed`); this._schedulePoll(true); resolve({ errorStateId: 0 }); } catch (err) { this._log.warn(`[Matter/${this.displayName}] Resume failed: ${err.message}`); resolve({ errorStateId: RVC_STATE.ERROR }); } }); }); } async _handleGoHome() { this._log.debug(`[Matter/${this.displayName}] goHome`); return new Promise((resolve) => { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.warn(`[Matter/${this.displayName}] Failed to dock: ${error?.message ?? 'Unknown'}`); resolve({ errorStateId: RVC_STATE.ERROR }); return; } try { await roomba.dock(); this._log.debug(`[Matter/${this.displayName}] Docking`); this._schedulePoll(true); resolve({ errorStateId: 0 }); } catch (err) { this._log.warn(`[Matter/${this.displayName}] Dock failed: ${err.message}`); resolve({ errorStateId: RVC_STATE.ERROR }); } }); }); } // --------------------------------------------------------------------------- // Private: start / stop helpers // --------------------------------------------------------------------------- _startCleaning() { return new Promise((resolve) => { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.warn(`[Matter/${this.displayName}] Failed to start: ${error?.message ?? 'Unknown'}`); resolve(); return; } try { if (this._cachedStatus.paused) { await roomba.resume(); this._log.debug(`[Matter/${this.displayName}] Resumed from pause`); } else if (this._cleanBehaviour === 'rooms') { await roomba.cleanRoom(this._mission); this._log.debug(`[Matter/${this.displayName}] Cleaning rooms`); } else { await roomba.clean(); this._log.debug(`[Matter/${this.displayName}] Cleaning everywhere`); } this._schedulePoll(true); } catch (err) { this._log.warn(`[Matter/${this.displayName}] Start failed: ${err.message}`); } resolve(); }); }); } _stopAndDock() { return new Promise((resolve) => { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.warn(`[Matter/${this.displayName}] Failed to stop: ${error?.message ?? 'Unknown'}`); resolve(); return; } try { const state = await roomba.getRobotState(['cleanMissionStatus']); const parsed = this._parseState(state); if (parsed.running) { await roomba.pause(); if (this._stopBehaviour === 'home') { await this._dockWhenStopped(roomba, 3000); } } else if (parsed.docking) { await roomba.pause(); } this._schedulePoll(true); } catch (err) { this._log.warn(`[Matter/${this.displayName}] Stop failed: ${err.message}`); } resolve(); }); }); } async _dockWhenStopped(roomba, pollingInterval) { try { const state = await roomba.getRobotState(['cleanMissionStatus']); switch (state.cleanMissionStatus?.phase) { case 'stop': await roomba.dock(); this._schedulePoll(true); break; case 'run': await new Promise(res => setTimeout(res, pollingInterval)); await this._dockWhenStopped(roomba, pollingInterval); break; default: break; } } catch (err) { this._log.warn(`[Matter/${this.displayName}] Dock when stopped failed: ${err.message}`); } } // --------------------------------------------------------------------------- // Private: Roomba connection management // --------------------------------------------------------------------------- async _connectedRoomba(attempts = 0) { return new Promise((resolve, reject) => { let connected = false; let failed = false; const roomba = new dorita980.Local(this._blid, this._robotpwd, this._ipaddress, 2, { ciphers: ROBOT_CIPHERS[this._currentCipherIndex], }); const timeout = setTimeout(() => { failed = true; roomba.end(); reject(new Error('Connect timed out')); }, CONNECT_TIMEOUT_MILLIS); roomba.on('state', (state) => { const parsed = this._parseState(state); this._mergeCachedStatus(parsed); }); const onError = (error) => { roomba.off('error', onError); roomba.end(); clearTimeout(timeout); if (!connected) { failed = true; if (this._shouldTryDifferentCipher(error) && attempts < ROBOT_CIPHERS.length) { this._currentCipherIndex = (this._currentCipherIndex + 1) % ROBOT_CIPHERS.length; this._connectedRoomba(attempts + 1).then(resolve).catch(reject); } else { reject(error); } } }; roomba.on('error', onError); const onConnect = () => { roomba.off('connect', onConnect); clearTimeout(timeout); if (failed) { return; } connected = true; resolve({ roomba, useCount: 0 }); }; roomba.on('connect', onConnect); }); } _connect(callback) { const promise = this._currentRoombaPromise ?? this._connectedRoomba(); this._currentRoombaPromise = promise; promise.then((holder) => { holder.useCount++; callback(null, holder.roomba).finally(() => { holder.useCount--; if (holder.useCount <= 0) { this._currentRoombaPromise = undefined; holder.roomba.end(); } }); }).catch((error) => { this._currentRoombaPromise = undefined; callback(error); }); } // --------------------------------------------------------------------------- // Private: state parsing // --------------------------------------------------------------------------- _parseState(state) { const status = { timestamp: Date.now() }; if (state.batPct !== undefined) { status.batteryLevel = state.batPct; } if (state.bin !== undefined) { status.binFull = state.bin.full; } if (state.cleanMissionStatus !== undefined) { switch (state.cleanMissionStatus.phase) { case 'run': status.running = true; status.charging = false; status.docking = false; break; case 'charge': case 'recharge': status.running = false; status.charging = true; status.docking = false; break; case 'hmUsrDock': case 'hmMidMsn': case 'hmPostMsn': status.running = false; status.charging = false; status.docking = true; break; case 'stop': case 'stuck': case 'evac': default: status.running = false; status.charging = false; status.docking = false; break; } status.paused = !status.running && state.cleanMissionStatus.cycle === 'clean'; } return status; } _mergeCachedStatus(status) { this._cachedStatus = { ...this._cachedStatus, timestamp: Date.now(), ...status, }; if (this._cachedStatus.running || this._cachedStatus.docking) { this._roombaLastActiveTimestamp = Date.now(); } // Push the updated state to Matter this._pushMatterState(this._cachedStatus); } // --------------------------------------------------------------------------- // Private: Matter state pushing // --------------------------------------------------------------------------- _pushMatterState(status) { const matterApi = this._api.matter; if (!matterApi?.updateAccessoryState) { return; } // Determine operational state and run mode let operationalState; let runMode; if (status.running) { operationalState = RVC_STATE.RUNNING; runMode = RVC_RUN_MODE.CLEANING; } else if (status.docking) { operationalState = RVC_STATE.SEEKING_CHARGER; runMode = RVC_RUN_MODE.IDLE; } else if (status.paused) { operationalState = RVC_STATE.PAUSED; runMode = RVC_RUN_MODE.IDLE; } else if (status.charging) { operationalState = RVC_STATE.CHARGING; runMode = RVC_RUN_MODE.IDLE; } else { operationalState = RVC_STATE.STOPPED; runMode = RVC_RUN_MODE.IDLE; } try { matterApi.updateAccessoryState(this.UUID, 'rvcOperationalState', { operationalState }) .catch((e) => this._log.debug(`[Matter/${this.displayName}] State push error: ${e?.message}`)); matterApi.updateAccessoryState(this.UUID, 'rvcRunMode', { currentMode: runMode }) .catch((e) => this._log.debug(`[Matter/${this.displayName}] State push error: ${e?.message}`)); if (status.batteryLevel !== undefined) { // Matter uses 0–200 for battery percentage (0.5% increments) const batPercentRemaining = Math.round(status.batteryLevel * 2); const batChargeLevel = status.batteryLevel <= 5 ? 2 : status.batteryLevel <= 15 ? 1 : 0; matterApi.updateAccessoryState(this.UUID, 'powerSource', { batPercentRemaining, batChargeLevel }) .catch((e) => this._log.debug(`[Matter/${this.displayName}] Battery push error: ${e?.message}`)); } } catch (e) { this._log.debug(`[Matter/${this.displayName}] State push failed: ${e?.message}`); } } // --------------------------------------------------------------------------- // Private: polling // --------------------------------------------------------------------------- _schedulePoll(adhoc) { if (!this._started) { return; } const now = Date.now(); if (adhoc && now - this._lastRefreshState < REFRESH_STATE_COALESCE_MILLIS) { return; } if (this._pollTimeout) { clearTimeout(this._pollTimeout); this._pollTimeout = undefined; } this._lastRefreshState = now; this._refreshState(() => { const interval = this._pollInterval(); this._pollTimeout = setTimeout(() => this._schedulePoll(false), interval); }); } _refreshState(callback) { this._connect(async (error, roomba) => { if (error || !roomba) { this._log.debug(`[Matter/${this.displayName}] Failed to refresh state: ${error?.message ?? 'Unknown'}`); callback(false); return; } return new Promise((resolve) => { let finished = false; const finish = (success) => { if (finished) { return; } finished = true; clearTimeout(timeout); roomba.off('state', onState); resolve(); callback(success); }; const timeout = setTimeout(() => { finish(false); }, STATUS_TIMEOUT_MILLIS); const onState = (state) => { const parsed = this._parseState(state); if (parsed.batteryLevel !== undefined && parsed.charging !== undefined && parsed.running !== undefined) { finish(true); } }; roomba.on('state', onState); }); }); } _pollInterval() { const timeSinceLastActive = Date.now() - (this._roombaLastActiveTimestamp ?? 0); const isActive = this._cachedStatus.running || this._cachedStatus.docking; if (isActive || timeSinceLastActive < AFTER_ACTIVE_MILLIS) { return 10_000; } return this._idlePollIntervalMillis; } // --------------------------------------------------------------------------- // Private: helpers // --------------------------------------------------------------------------- _getSerialNum(device) { if (device.info?.serialNum) { return device.info.serialNum; } return device.ipaddress ?? device.ip ?? device.blid; } _shouldTryDifferentCipher(error) { if (error.message.includes('TLS')) { return true; } if (error.message.toLowerCase().includes('identifier rejected')) { return true; } return false; } } //# sourceMappingURL=matterAccessory.js.map