From 0d0444b9b2fed29815cf94ab259eac227dc76cac Mon Sep 17 00:00:00 2001 From: Karl von Randow Date: Tue, 21 Jan 2025 23:07:26 +1300 Subject: [PATCH] feat: convert to a platform plugin --- config.schema.json | 326 +++++++++++++++++++++++---------------------- src/accessory.ts | 64 +++++---- src/index.ts | 12 +- src/platform.ts | 110 +++++++++++++++ src/settings.ts | 4 +- src/types.ts | 25 ++++ 6 files changed, 337 insertions(+), 204 deletions(-) create mode 100644 src/platform.ts create mode 100644 src/types.ts diff --git a/config.schema.json b/config.schema.json index 87d8fc3..3564582 100644 --- a/config.schema.json +++ b/config.schema.json @@ -1,180 +1,184 @@ { "pluginAlias": "Roomba2", - "pluginType": "accessory", + "pluginType": "platform", "customUi": true, "customUiPath": "./dist/homebridge-ui", "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Name", - "required": true - }, - "model": { - "type": "string", - "title": "Model", - "required": true - }, - "serialnum": { - "type": "string", - "title": "Serial Number", - "required": false - }, - "blid": { - "type": "string", - "title": "blid", - "required": true - }, - "robotpwd": { - "type": "string", - "title": "Robot Password", - "required": true - }, - "ipaddress": { - "type": "string", - "title": "IP Address", - "required": true - }, - "debug": { - "type": "boolean", - "title": "Debug logging", - "required": false - }, - "dockContactSensor": { - "type": "boolean", - "title": "Home", - "default": true, - "required": false - }, - "runningContactSensor": { - "type": "boolean", - "title": "Running", - "required": false - }, - "binContactSensor": { - "type": "boolean", - "title": "Bin Full", - "required": false - }, - "dockingContactSensor": { - "type": "boolean", - "title": "Returning Home", - "required": false - }, - "tankContactSensor": { - "type": "boolean", - "title": "Braava Water Tank", - "required": false - }, - "homeSwitch": { - "type": "boolean", - "title": "Home", - "required": false - }, - "cleanBehaviour": { - "type": "string", - "title": "When Roomba is turned on", - "required": true, - "default": "everywhere", - "oneOf": [ - { "title": "Clean everywhere", "enum": ["everywhere"] }, - { "title": "Clean specific rooms", "enum": ["rooms"] } - ] - }, - "mission": { - "type": "object", - "title": "Mission Info", - "properties": { - "ordered": { - "type": "number", - "title": "Clean rooms in order", - "default": 1, - "required": true, - "oneOf": [ - { "title": "Yes", "enum": [1] }, - { "title": "No", "enum": [0] } - ], - "condition": { - "functionBody": "return model.cleanBehaviour === 'rooms';" - } - }, - "pmap_id": { - "type": "string", - "title": "Pmap Id", - "required": true, - "condition": { - "functionBody": "return model.cleanBehaviour === 'rooms';" - } - }, - "regions": { - "type": "array", - "title": "Rooms to be cleaned", - "items": { - "type": "object", - "properties": { - "region_id": { - "type": "string", - "title": "Region Id", - "required": true - }, - "type": { - "type": "string", - "title": "Type", - "default": "rid", - "required": true - }, - "params": { - "type": "object", - "properties": { - "noAutoPasses": { - "type": "boolean", - "required": false, - "default": false, - "title": "Specify Number of Cleaning Passes" - }, - "twoPass": { - "type": "boolean", - "required": false, - "default": false, - "title": "Two Passes" + "type": "array", + "title": "Devices", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "required": true + }, + "model": { + "type": "string", + "title": "Model", + "required": true + }, + "serialnum": { + "type": "string", + "title": "Serial Number", + "required": false + }, + "blid": { + "type": "string", + "title": "blid", + "required": true + }, + "robotpwd": { + "type": "string", + "title": "Robot Password", + "required": true + }, + "ipaddress": { + "type": "string", + "title": "IP Address", + "required": true + }, + "debug": { + "type": "boolean", + "title": "Debug logging", + "required": false + }, + "dockContactSensor": { + "type": "boolean", + "title": "Home", + "default": true, + "required": false + }, + "runningContactSensor": { + "type": "boolean", + "title": "Running", + "required": false + }, + "binContactSensor": { + "type": "boolean", + "title": "Bin Full", + "required": false + }, + "dockingContactSensor": { + "type": "boolean", + "title": "Returning Home", + "required": false + }, + "tankContactSensor": { + "type": "boolean", + "title": "Braava Water Tank", + "required": false + }, + "homeSwitch": { + "type": "boolean", + "title": "Home", + "required": false + }, + "cleanBehaviour": { + "type": "string", + "title": "When Roomba is turned on", + "required": true, + "default": "everywhere", + "oneOf": [ + { "title": "Clean everywhere", "enum": ["everywhere"] }, + { "title": "Clean specific rooms", "enum": ["rooms"] } + ] + }, + "mission": { + "type": "object", + "title": "Mission Info", + "properties": { + "ordered": { + "type": "number", + "title": "Clean rooms in order", + "default": 1, + "required": true, + "oneOf": [ + { "title": "Yes", "enum": [1] }, + { "title": "No", "enum": [0] } + ], + "condition": { + "functionBody": "return model.cleanBehaviour === 'rooms';" + } + }, + "pmap_id": { + "type": "string", + "title": "Pmap Id", + "required": true, + "condition": { + "functionBody": "return model.cleanBehaviour === 'rooms';" + } + }, + "regions": { + "type": "array", + "title": "Rooms to be cleaned", + "items": { + "type": "object", + "properties": { + "region_id": { + "type": "string", + "title": "Region Id", + "required": true + }, + "type": { + "type": "string", + "title": "Type", + "default": "rid", + "required": true + }, + "params": { + "type": "object", + "properties": { + "noAutoPasses": { + "type": "boolean", + "required": false, + "default": false, + "title": "Specify Number of Cleaning Passes" + }, + "twoPass": { + "type": "boolean", + "required": false, + "default": false, + "title": "Two Passes" + } } } } + }, + "condition": { + "functionBody": "return model.cleanBehaviour === 'rooms';" } }, - "condition": { - "functionBody": "return model.cleanBehaviour === 'rooms';" - } - }, - "user_pmapv_id": { - "type": "string", - "title": "User Pmapv Id", - "required": true, - "condition": { - "functionBody": "return model.cleanBehaviour === 'rooms';" + "user_pmapv_id": { + "type": "string", + "title": "User Pmapv Id", + "required": true, + "condition": { + "functionBody": "return model.cleanBehaviour === 'rooms';" + } } } + }, + "stopBehaviour": { + "type": "string", + "title": "When Roomba is turned off", + "required": true, + "default": "home", + "oneOf": [ + { "title": "Home", "enum": ["home"] }, + { "title": "Pause", "enum": ["pause"] } + ] } - }, - "stopBehaviour": { - "type": "string", - "title": "When Roomba is turned off", - "required": true, - "default": "home", - "oneOf": [ - { "title": "Home", "enum": ["home"] }, - { "title": "Pause", "enum": ["pause"] } - ] - }, - "idleWatchInterval": { - "type": "integer", - "title": "Idle Poll Interval (minutes)", - "description": "How often to poll Roomba's status when it is idle. Defaults to 15 minutes.", - "required": false } } }, + "idleWatchInterval": { + "type": "integer", + "title": "Idle Poll Interval (minutes)", + "description": "How often to poll Roomba's status when it is idle. Defaults to 15 minutes.", + "required": false + }, "headerDisplay": "For more information and help please consult the [README](https://github.com/homebridge-plugins/homebridge-roomba2#setup).", "layout": [ { diff --git a/src/accessory.ts b/src/accessory.ts index 82e3ac7..b54b29f 100644 --- a/src/accessory.ts +++ b/src/accessory.ts @@ -1,5 +1,8 @@ import type { RobotMission, RobotState, Roomba } from 'dorita980' -import type { AccessoryConfig, AccessoryPlugin, API, CharacteristicGetCallback, CharacteristicSetCallback, CharacteristicValue, Logging, Service } from 'homebridge' +import type { AccessoryConfig, AccessoryPlugin, API, CharacteristicGetCallback, CharacteristicSetCallback, CharacteristicValue, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, Service } from 'homebridge' + +import type RoombaPlatform from './platform' +import type { DeviceConfig } from './types' import { readFileSync } from 'node:fs' @@ -74,19 +77,16 @@ async function delay(duration: number) { }) } -export default class RoombaAccessory implements AccessoryPlugin { - private api: API - private log: Logging +export default class RoombaAccessory { private name: string private model: string - private serialnum: string + private serialnum?: string private blid: string private robotpwd: string private ipaddress: string private cleanBehaviour: 'everywhere' | 'rooms' - private mission: RobotMission + private mission?: RobotMission private stopBehaviour: 'home' | 'pause' - private debug: boolean private idlePollIntervalMillis: number private accessoryInfo: Service @@ -139,17 +139,13 @@ export default class RoombaAccessory implements AccessoryPlugin { */ private currentCipherIndex = 0 - public constructor(log: Logging, config: AccessoryConfig, api: API) { - this.api = api - this.debug = !!config.debug + constructor( + private readonly platform: RoombaPlatform, + private readonly accessory: PlatformAccessory, + private readonly log: Logging, + ) { + const config: DeviceConfig = accessory.context.device - this.log = !this.debug - ? log - : Object.assign(log, { - debug: (message: string, ...parameters: unknown[]) => { - log.info(`DEBUG: ${message}`, ...parameters) - }, - }) this.name = config.name this.model = config.model this.serialnum = config.serialnum @@ -168,7 +164,7 @@ export default class RoombaAccessory implements AccessoryPlugin { const showHomeSwitch = config.homeSwitch const showTankAsFilterMaintenance = config.tankContactSensor - const Service = api.hap.Service + const Service = platform.Service this.accessoryInfo = new Service.AccessoryInformation() this.filterMaintenance = new Service.FilterMaintenance(this.name) @@ -194,12 +190,12 @@ export default class RoombaAccessory implements AccessoryPlugin { this.homeService = new Service.Switch(`${this.name} Home`, 'returning') } - const Characteristic = this.api.hap.Characteristic + const Characteristic = platform.Characteristic const version: string = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version this.accessoryInfo.setCharacteristic(Characteristic.Manufacturer, 'iRobot') - this.accessoryInfo.setCharacteristic(Characteristic.SerialNumber, this.serialnum) + this.accessoryInfo.setCharacteristic(Characteristic.SerialNumber, this.serialnum || '1') this.accessoryInfo.setCharacteristic(Characteristic.Identify, true) this.accessoryInfo.setCharacteristic(Characteristic.Name, this.name) this.accessoryInfo.setCharacteristic(Characteristic.Model, this.model) @@ -486,7 +482,7 @@ export default class RoombaAccessory implements AccessoryPlugin { if (this.cachedStatus.paused) { await roomba.resume() } else { - if (this.cleanBehaviour === 'rooms') { + if (this.cleanBehaviour === 'rooms' && this.mission) { await roomba.cleanRoom(this.mission) this.log.debug('Roomba is cleaning your rooms') } else { @@ -767,7 +763,7 @@ export default class RoombaAccessory implements AccessoryPlugin { } } - const Characteristic = this.api.hap.Characteristic + const Characteristic = this.platform.Characteristic updateCharacteristic(this.switchService, Characteristic.On, this.runningStatus) updateCharacteristic(this.batteryService, Characteristic.ChargingState, this.chargingStatus) @@ -879,20 +875,20 @@ export default class RoombaAccessory implements AccessoryPlugin { private chargingStatus = (status: Status) => status.charging === undefined ? undefined : status.charging - ? this.api.hap.Characteristic.ChargingState.CHARGING - : this.api.hap.Characteristic.ChargingState.NOT_CHARGING + ? this.platform.Characteristic.ChargingState.CHARGING + : this.platform.Characteristic.ChargingState.NOT_CHARGING private dockingStatus = (status: Status) => status.docking === undefined ? undefined : status.docking - ? this.api.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED - : this.api.hap.Characteristic.ContactSensorState.CONTACT_DETECTED + ? this.platform.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED + : this.platform.Characteristic.ContactSensorState.CONTACT_DETECTED private dockedStatus = (status: Status) => status.charging === undefined ? undefined : status.charging - ? this.api.hap.Characteristic.ContactSensorState.CONTACT_DETECTED - : this.api.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED + ? this.platform.Characteristic.ContactSensorState.CONTACT_DETECTED + : this.platform.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED private batteryLevelStatus = (status: Status) => status.batteryLevel === undefined ? undefined @@ -901,20 +897,20 @@ export default class RoombaAccessory implements AccessoryPlugin { private binStatus = (status: Status) => status.binFull === undefined ? undefined : status.binFull - ? this.api.hap.Characteristic.FilterChangeIndication.CHANGE_FILTER - : this.api.hap.Characteristic.FilterChangeIndication.FILTER_OK + ? this.platform.Characteristic.FilterChangeIndication.CHANGE_FILTER + : this.platform.Characteristic.FilterChangeIndication.FILTER_OK private batteryStatus = (status: Status) => status.batteryLevel === undefined ? undefined : status.batteryLevel <= 20 - ? this.api.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW - : this.api.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL + ? this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW + : this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL private tankStatus = (status: Status) => status.tankLevel === undefined ? undefined : status.tankLevel - ? this.api.hap.Characteristic.FilterChangeIndication.FILTER_OK - : this.api.hap.Characteristic.FilterChangeIndication.CHANGE_FILTER + ? this.platform.Characteristic.FilterChangeIndication.FILTER_OK + : this.platform.Characteristic.FilterChangeIndication.CHANGE_FILTER private tankLevelStatus = (status: Status) => status.tankLevel === undefined ? undefined diff --git a/src/index.ts b/src/index.ts index 804d23c..c2b27a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,10 @@ */ import type { API } from 'homebridge' -import RoombaAccessory from './accessory.js' -import { ACCESSORY_NAME, PLUGIN_NAME } from './settings.js' +import RoombaPlatform from './platform.js' +import { PLATFORM_NAME } from './settings.js' -/** - * This method registers the platform with Homebridge - */ -export default function (api: API): void { - api.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, RoombaAccessory) +// Register our platform with homebridge. +export default (api: API): void => { + api.registerPlatform(PLATFORM_NAME, RoombaPlatform) } diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..4ae1c29 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,110 @@ +import type { API, Characteristic, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, Service } from 'homebridge' + +import type { DeviceConfig } from './types.js' + +import RoombaAccessory from './accessory.js' +import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js' + +export default class RoombaPlatform implements DynamicPlatformPlugin { + public readonly Service: typeof Service + public readonly Characteristic: typeof Characteristic + + private api: API + private log: Logging + private config: PlatformConfig + private readonly accessories: Map = new Map() + + public constructor(log: Logging, config: PlatformConfig, api: API) { + this.Service = api.hap.Service + this.Characteristic = api.hap.Characteristic + + this.api = api + this.config = config + const debug = !!config.debug + + this.log = !debug + ? log + : Object.assign(log, { + debug: (message: string, ...parameters: unknown[]) => { + log.info(`DEBUG: ${message}`, ...parameters) + }, + }) + + this.api.on('didFinishLaunching', () => { + this.discoverDevices() + }) + } + + public configureAccessory(accessory: PlatformAccessory): void { + this.log(`Configuring accessory: ${accessory.displayName}`) + + this.accessories.set(accessory.UUID, accessory) + } + + private discoverDevices(): void { + const devices: DeviceConfig[] = this.getDevicesFromConfig() + const configuredAccessoryUUIDs = new Set() + + for (const device of devices) { + const uuid = this.api.hap.uuid.generate(device.blid) + + const existingAccessory = this.accessories.get(uuid) + if (existingAccessory) { + // the accessory already exists + this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName) + + // TODO when should we update the device config + + // if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. e.g.: + existingAccessory.context.device = device + // this.api.updatePlatformAccessories([existingAccessory]); + + // create the accessory handler for the restored accessory + // this is imported from `platformAccessory.ts` + new RoombaAccessory(this, existingAccessory, this.log) + + // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, e.g.: + // remove platform accessories when no longer present + // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]); + // this.log.info('Removing existing accessory from cache:', existingAccessory.displayName); + } else { + // the accessory does not yet exist, so we need to create it + this.log.info('Adding new accessory:', device.name) + + // create a new accessory + const accessory = new this.api.platformAccessory(device.name, uuid) + + // store a copy of the device object in the `accessory.context` + // the `context` property can be used to store any data about the accessory you may need + accessory.context.device = device + + // create the accessory handler for the newly create accessory + // this is imported from `platformAccessory.ts` + new RoombaAccessory(this, accessory, this.log) + + // link the accessory to your platform + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]) + } + configuredAccessoryUUIDs.add(uuid) + } + + // you can also deal with accessories from the cache which are no longer present by removing them from Homebridge + // for example, if your plugin logs into a cloud account to retrieve a device list, and a user has previously removed a device + // from this cloud account, then this device will no longer be present in the device list but will still be in the Homebridge cache + const accessoriesToRemove: PlatformAccessory[] = [] + for (const [uuid, accessory] of this.accessories) { + if (!configuredAccessoryUUIDs.has(uuid)) { + accessoriesToRemove.push(accessory) + } + } + + if (accessoriesToRemove.length) { + this.log.info('Removing existing accessories from cache:', accessoriesToRemove.map(a => a.displayName).join(', ')) + this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemove) + } + } + + private getDevicesFromConfig(): DeviceConfig[] { + return this.config.devices || [] + } +} diff --git a/src/settings.ts b/src/settings.ts index 205ae0b..7947e8b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,7 @@ /** - * This is the name of the accessory that users will use to create an accessory in the Homebridge config.json + * This is the name of the platform that users will use to register the plugin in the Homebridge config.json */ -export const ACCESSORY_NAME = 'Roomba2' +export const PLATFORM_NAME = 'Roomba2' /** * This must match the name of your plugin as defined the package.json diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2ad9ce3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +import type { RobotMission } from 'dorita980' + +export interface DeviceConfig { + name: string + model: string + serialnum?: string + blid: string + robotpwd: string + ipaddress: string + cleanBehaviour: 'everywhere' | 'rooms' + mission?: RobotMission + stopBehaviour: 'home' | 'pause' + /** + * Idle Poll Interval (minutes). + * How often to poll Roomba's status when it is idle. + */ + idleWatchInterval: number + + dockContactSensor?: boolean + runningContactSensor?: boolean + binContactSensor?: boolean + dockingContactSensor?: boolean + homeSwitch?: boolean + tankContactSensor?: boolean +}