UNPKG

homebridge-kobold

Version:

A Vorwerk Kobold vacuum robot plugin for homebridge.

344 lines (288 loc) 10.6 kB
import type { API, Characteristic, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, Service, } from 'homebridge'; import Debug from 'debug'; import control from 'node-kobold-control'; import type { KoboldRobot, KoboldMap, KoboldRobotState, KoboldMapBoundary } from 'node-kobold-control'; import { buildSpotCharacteristics, type SpotCharacteristicConstructors } from './customCharacteristics.js'; import { KoboldVacuumAccessory } from './platformAccessory.js'; import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; const debug = Debug('homebridge-kobold'); export interface KoboldPlatformConfig extends PlatformConfig { token?: string; language?: string; refresh?: string | number; hidden?: string[] | string; disabled?: string[] | string; } export interface KoboldBoundary { id: string; name: string; type?: string; } export interface RobotRecord { device: KoboldRobot; meta: Record<string, unknown>; availableServices: Record<string, unknown>; mainAccessory?: KoboldVacuumAccessory; timer?: NodeJS.Timeout; lastUpdate?: Date; } type RefreshSetting = number | 'auto'; export class KoboldHomebridgePlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service; public readonly Characteristic: typeof Characteristic; public readonly accessories: Map<string, PlatformAccessory> = new Map(); public readonly discoveredCacheUUIDs: string[] = []; public readonly robots: RobotRecord[] = []; public nextRoom: string | null = null; public readonly language: string; public readonly hiddenServices: string[] | string; public readonly refresh: RefreshSetting; public readonly spotCharacteristics: SpotCharacteristicConstructors; private readonly token: string; constructor( public readonly log: Logging, public readonly config: PlatformConfig, public readonly api: API, ) { this.Service = api.hap.Service; this.Characteristic = api.hap.Characteristic; this.spotCharacteristics = buildSpotCharacteristics(this.api); const platformConfig = (config ?? {}) as KoboldPlatformConfig; this.token = platformConfig.token ?? ''; this.language = ['en', 'de', 'fr'].includes(platformConfig.language ?? '') ? platformConfig.language! : 'en'; let hiddenServices: string[] | string = []; if (platformConfig.disabled !== undefined) { hiddenServices = platformConfig.disabled; } if (platformConfig.hidden !== undefined) { hiddenServices = platformConfig.hidden; } this.hiddenServices = hiddenServices; this.refresh = this.parseRefresh(platformConfig); this.log(`Refresh is set to: ${this.refresh}${this.refresh !== 'auto' ? ' seconds' : ''}`); if (!this.token) { this.log.error('No Kobold token configured. Please update your config and restart Homebridge.'); } this.api.on('didFinishLaunching', () => { debug('Executed didFinishLaunching callback'); void this.discoverRobots(); }); } configureAccessory(accessory: PlatformAccessory) { this.accessories.set(accessory.UUID, accessory); } public isServiceHidden(key: string): boolean { if (Array.isArray(this.hiddenServices)) { return this.hiddenServices.includes(key); } if (typeof this.hiddenServices === 'string') { return this.hiddenServices.indexOf(key) !== -1; } return false; } private parseRefresh(config: KoboldPlatformConfig): RefreshSetting { if ('refresh' in config && config.refresh !== undefined && config.refresh !== 'auto') { const parsed = parseInt(String(config.refresh), 10); if (Number.isNaN(parsed) || parsed < 0) { return 60; } if (parsed > 0 && parsed < 60) { this.log.warn('Minimum refresh time is 60 seconds to not overload the Vorwerk servers'); return 60; } if (parsed === 0) { return 0; } return parsed; } return 'auto'; } private async discoverRobots(): Promise<void> { if (!this.token) { return; } this.discoveredCacheUUIDs.length = 0; try { const robots = await this.loadRobots(); this.robots.length = 0; robots.forEach(robot => { this.robots.push(robot); }); this.robots.forEach((robot, index) => { this.log.info( `Found robot #${index + 1} named "${robot.device.name}" with serial "${robot.device._serial.substring(0, 9)}XXXXXXXXXXXX"`, ); this.setupMainAccessory(robot); this.updateRobotTimer(robot.device._serial); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log.error(`Failed to discover robots: ${message}`); } this.cleanupAccessories(); } private async loadRobots(): Promise<RobotRecord[]> { debug('Loading your robots'); const client = new control.Client(); await new Promise<void>((resolve, reject) => { client.authorize(this.token, (err: unknown) => { if (err) { this.log.error( `Can't log on to Vorwerk cloud. Please check your internet connection and your token. Try again later if the Vorwerk servers have issues: ${err}`, ); reject(err); } else { resolve(); } }); }); const robots = await new Promise<KoboldRobot[]>((resolve, reject) => { client.getRobots((error: unknown, robotList: KoboldRobot[]) => { if (error) { this.log.error(`Successful login but can't connect to your Vorwerk robot: ${error}`); reject(error); } else { resolve(robotList); } }); }); if (!robots.length) { this.log.error('Successful login but no robots associated with your account.'); return []; } const robotRecords: RobotRecord[] = []; for (const robot of robots) { const state = await new Promise<KoboldRobotState>((resolve, reject) => { robot.getState((error: unknown, result: KoboldRobotState) => { if (error) { this.log.error(`Error getting robot meta information: ${error}: ${result}`); reject(error); } else { resolve(result); } }); }); const record: RobotRecord = { device: robot, meta: state.meta ?? {}, availableServices: state.availableServices ?? {}, }; const maps = await new Promise<KoboldMap[]>((resolve, reject) => { robot.getPersistentMaps((error: unknown, robotMaps: KoboldMap[]) => { if (error) { this.log.error(`Error updating persistent maps: ${error}: ${robotMaps}`); reject(error); } else { resolve(robotMaps || []); } }); }); if (!maps.length) { robot.maps = []; } else { await Promise.all( maps.map( (map: KoboldMap) => new Promise<void>(resolve => { robot.getMapBoundaries(map.id, (error: unknown, result: { boundaries: KoboldMapBoundary[] }) => { if (error) { this.log.error(`Error getting boundaries: ${error}: ${result}`); } else { map.boundaries = result.boundaries; } resolve(); }); }), ), ); robot.maps = maps; } robotRecords.push(record); } return robotRecords; } private setupMainAccessory(robot: RobotRecord) { const uuid = this.api.hap.uuid.generate(robot.device._serial); const displayName = robot.device.name; const accessory = this.prepareAccessory(uuid, displayName); accessory.context.robotSerial = robot.device._serial; accessory.context.boundaryId = null; accessory.context.boundaryName = null; const handler = new KoboldVacuumAccessory(this, accessory, robot); robot.mainAccessory = handler; this.discoveredCacheUUIDs.push(uuid); } private prepareAccessory(uuid: string, name: string): PlatformAccessory { const existingAccessory = this.accessories.get(uuid); if (existingAccessory) { existingAccessory.displayName = name; this.api.updatePlatformAccessories([existingAccessory]); this.accessories.set(uuid, existingAccessory); return existingAccessory; } const accessory = new this.api.platformAccessory(name, uuid); this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.accessories.set(uuid, accessory); return accessory; } private cleanupAccessories(): void { for (const [uuid, accessory] of this.accessories) { if (!this.discoveredCacheUUIDs.includes(uuid)) { this.log.info('Removing existing accessory from cache:', accessory.displayName); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.accessories.delete(uuid); } } } public getRobot(serial: string): RobotRecord | undefined { return this.robots.find(robot => robot.device._serial === serial); } public async updateRobot(serial: string): Promise<void> { const robot = this.getRobot(serial); if (!robot) { return; } if (robot.lastUpdate && new Date().getTime() - robot.lastUpdate.getTime() < 2000) { return; } debug(`${robot.device.name}: ++ Updating robot state`); robot.lastUpdate = new Date(); await new Promise<void>(resolve => { robot.device.getState((error: unknown) => { if (error) { this.log.error('Cannot update robot. Check if robot is online. ' + error); } resolve(); }); }); } public updateRobotTimer(serial: string): void { const robot = this.getRobot(serial); if (!robot) { return; } void this.updateRobot(serial).finally(() => { clearTimeout(robot.timer); robot.mainAccessory?.updated(); if (this.refresh !== 'auto' && this.refresh !== 0) { debug(`${robot.device.name}: ++ Next background update in ${this.refresh} seconds`); robot.timer = setTimeout(() => this.updateRobotTimer(serial), this.refresh * 1000); } else if (this.refresh === 'auto' && robot.device.canPause) { debug(`${robot.device.name}: ++ Next background update in 60 seconds while cleaning (auto mode)`); robot.timer = setTimeout(() => this.updateRobotTimer(serial), 60 * 1000); } else { debug(`${robot.device.name}: ++ Stopped background updates`); } }); } }