diff --git a/CHANGELOG.md b/CHANGELOG.md index 213e150..fca2a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.1.0](https://github.com/homebridge-plugins/homebridge-roomba/releases/tag/v2.1.0) (2025-01-XX) + +### What's Changes +- Added option to use iRobot credentials to get devices info. +- Set default idleWatchInterval for plugin, but allow for override per device or overall plugin. +- Housekeeping and updated dependencies. + +**Full Changelog**: https://github.com/homebridge-plugins/homebridge-roomba/compare/v2.0.0...v2.1.0 + ## [2.0.0](https://github.com/homebridge-plugins/homebridge-roomba/releases/tag/v2.0.0) (2025-01-25) ### What's Changes diff --git a/config.schema.json b/config.schema.json index abd76ac..494d965 100644 --- a/config.schema.json +++ b/config.schema.json @@ -11,9 +11,34 @@ "name": { "type": "string", "title": "Name", - "default": "NoIP", + "default": "Roomba", "required": true }, + "email": { + "type": "string", + "title": "iRobot Account Email", + "description": "The email address you use to log into the iRobot Home app.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "required": true, + "x-schema-form": { + "type": "email" + } + }, + "password": { + "type": "string", + "title": "iRobot Account Password", + "description": "The password you use to log into the iRobot Home app.", + "required": true, + "x-schema-form": { + "type": "password" + } + }, + "disableDiscovery": { + "type": "boolean", + "title": "Disable Discovery", + "description": "Disable automatic discovery of Roomba devices. If you have multiple Roomba devices, you can disable discovery and manually configure each device.", + "required": false + }, "devices": { "type": "array", "items": { @@ -109,7 +134,6 @@ "type": "number", "title": "Clean rooms in order", "default": 1, - "required": true, "oneOf": [ { "title": "Yes", @@ -131,7 +155,6 @@ "pmap_id": { "type": "string", "title": "Pmap Id", - "required": true, "condition": { "functionBody": "return (model.devices && model.devices[arrayIndices].cleanBehaviour === 'rooms');" } @@ -144,14 +167,12 @@ "properties": { "region_id": { "type": "string", - "title": "Region Id", - "required": true + "title": "Region Id" }, "type": { "type": "string", "title": "Type", - "default": "rid", - "required": true + "default": "rid" }, "params": { "type": "object", @@ -215,6 +236,12 @@ } } }, + "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 + }, "debug": { "type": "boolean", "title": "Debug logging", @@ -224,47 +251,69 @@ }, "layout": [ { - "key": "devices", - "notitle": false, - "type": "tabarray", - "title": "{{ value.name || value.ipaddress || value.serialnum || 'New Vacuum' }}", + "type": "fieldset", + "title": "iRobot Account", + "expandable": true, + "expanded": false, + "items": [ + "email", + "password" + ] + }, + { + "type": "fieldset", + "title": "Roomba Device Settings", "expandable": true, "expanded": false, - "orderable": false, "items": [ - "devices[].name", - "devices[].model", - "devices[].serialnum", - "devices[].blid", - "devices[].robotpwd", - "devices[].ipaddress", - "devices[].homeSwitch", - "devices[].dockContactSensor", - "devices[].dockingContactSensor", - "devices[].runningContactSensor", - "devices[].binContactSensor", - "devices[].tankContactSensor", - "devices[].idleWatchInterval", - "devices[].cleanBehaviour", { - "key": "devices[].mission", - "type": "fieldset", - "title": "Mission Settings", + "type": "help", + "helpvalue": "With Roomba Device Setting, you can set device specific settings based on blid." + }, + { + "key": "devices", + "notitle": false, + "type": "tabarray", + "title": "{{ value.name || value.ipaddress || value.serialnum || 'New Vacuum' }}", "expandable": true, "expanded": false, + "orderable": false, "items": [ - "devices[].mission.pmap_id", - "devices[].mission.user_pmapv_id", - "devices[].mission.regions", - "devices[].mission.regions[].region_id", - "devices[].mission.regions[].type", - "devices[].mission.regions[].params.noAutoPasses", - "devices[].mission.regions[].params.twoPass", - "devices[].mission.ordered" + "devices[].name", + "devices[].model", + "devices[].serialnum", + "devices[].blid", + "devices[].robotpwd", + "devices[].ipaddress", + "devices[].homeSwitch", + "devices[].dockContactSensor", + "devices[].dockingContactSensor", + "devices[].runningContactSensor", + "devices[].binContactSensor", + "devices[].tankContactSensor", + "devices[].idleWatchInterval", + "devices[].cleanBehaviour", + { + "key": "devices[].mission", + "type": "fieldset", + "title": "Mission Settings", + "expandable": true, + "expanded": false, + "items": [ + "devices[].mission.pmap_id", + "devices[].mission.user_pmapv_id", + "devices[].mission.regions", + "devices[].mission.regions[].region_id", + "devices[].mission.regions[].type", + "devices[].mission.regions[].params.noAutoPasses", + "devices[].mission.regions[].params.twoPass", + "devices[].mission.ordered" + ] + }, + "devices[].stopBehaviour", + "devices[].idleWatchInterval" ] - }, - "devices[].stopBehaviour", - "devices[].idleWatchInterval" + } ] }, { @@ -273,6 +322,8 @@ "expandable": true, "expanded": false, "items": [ + "idleWatchInterval", + "disableDiscovery", "debug" ] } diff --git a/docs/functions/default.html b/docs/functions/default.html index 78592c8..5730451 100644 --- a/docs/functions/default.html +++ b/docs/functions/default.html @@ -1,2 +1,2 @@ default | @homebridge-plugins/homebridge-roomba
-

    Function default

    • Parameters

      • api: API

      Returns void

    \ No newline at end of file +

      Function default

      • Parameters

        • api: API

        Returns void

      \ No newline at end of file diff --git a/docs/media/CHANGELOG.md b/docs/media/CHANGELOG.md index c5adeb4..35409f7 100644 --- a/docs/media/CHANGELOG.md +++ b/docs/media/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.1.0](https://github.com/homebridge-plugins/homebridge-roomba/releases/tag/v2.1.0) (2025-01-XX) + +### What's Changes +- Added option to use iRobot credentials to get devices info. +- Housekeeping and updated dependencies. + +**Full Changelog**: https://github.com/homebridge-plugins/homebridge-roomba/compare/v2.0.0...v2.1.0 + ## [2.0.0](https://github.com/homebridge-plugins/homebridge-roomba/releases/tag/v2.0.0) (2025-01-25) ### What's Changes @@ -8,6 +16,7 @@ - ***You will need to re-setup your vacuum*** - You can reuse your config by taking your `serialnum`, `blid`, `robotpwd`, `ipaddress` from the Accessory config and input it in the Homebridge UI when adding a new device. - Pluging has been renamed from `homebridge-roomba2` to `homebridge-roomba` now that it is a scoped plugin. +- Compaitable with Homebridge `v2.0.0` #### Other Changes - Housekeeping and updated dependencies. diff --git a/package-lock.json b/package-lock.json index 5a2b4f6..3273c39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@types/debug": "^4.1.12", "@types/fs-extra": "^11.0.4", "@types/mdast": "^4.0.4", - "@types/node": "^22.10.10", + "@types/node": "^22.13.0", "@types/promise-timeout": "^1.3.0", "@types/semver": "^7.5.8", "@types/source-map-support": "^0.5.10", @@ -2954,9 +2954,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz", - "integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==", + "version": "22.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", + "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 636b04c..066ecc8 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@types/debug": "^4.1.12", "@types/fs-extra": "^11.0.4", "@types/mdast": "^4.0.4", - "@types/node": "^22.10.10", + "@types/node": "^22.13.0", "@types/promise-timeout": "^1.3.0", "@types/semver": "^7.5.8", "@types/source-map-support": "^0.5.10", diff --git a/src/accessory.ts b/src/accessory.ts index 4c83130..6f7d143 100644 --- a/src/accessory.ts +++ b/src/accessory.ts @@ -2,7 +2,8 @@ import type { RobotMission, RobotState, Roomba } from 'dorita980' import type { AccessoryPlugin, API, CharacteristicGetCallback, CharacteristicSetCallback, CharacteristicValue, Logging, PlatformAccessory, Service, WithUUID } from 'homebridge' import type RoombaPlatform from './platform.js' -import type { DeviceConfig, RoombaPlatformConfig } from './types.js' +import type { DeviceInfo, Robot } from './roomba.js' +import type { DeviceConfig, RoombaPlatformConfig } from './settings.js' import dorita980 from 'dorita980' @@ -80,7 +81,7 @@ export default class RoombaAccessory implements AccessoryPlugin { private log: Logging private name: string private model: string - private serialnum: string + private serialnum!: string private blid: string private robotpwd: string private ipaddress: string @@ -89,6 +90,7 @@ export default class RoombaAccessory implements AccessoryPlugin { private stopBehaviour: 'home' | 'pause' private debug: boolean private idlePollIntervalMillis: number + private deviceInfo?: DeviceInfo private version: string private accessoryInfo: Service @@ -145,26 +147,37 @@ export default class RoombaAccessory implements AccessoryPlugin { readonly platform: RoombaPlatform, accessory: PlatformAccessory, log: Logging, - device: DeviceConfig, + device: Robot & DeviceConfig, config: RoombaPlatformConfig, api: API, ) { this.api = api + this.log = log this.debug = !!config.debug - this.log = !this.debug ? log : Object.assign(log, { debug: (message: string, ...parameters: unknown[]) => { log.info(`DEBUG: ${message}`, ...parameters) } }) + if (!this.debug) { + this.log = log + } else { + this.log = Object.assign(log, { + debug: (message: string, ...parameters: unknown[]) => { + log.info(`DEBUG: ${message}`, ...parameters) + }, + }) + } + this.name = device.name this.model = device.model - this.serialnum = device.serialnum ?? device.ipaddress + const { serialNumber, deviceInfo } = this.serialNum(device) + this.deviceInfo = deviceInfo + this.serialnum = serialNumber this.blid = device.blid - this.robotpwd = device.robotpwd - this.ipaddress = device.ipaddress - this.version = this.platform.version ?? '0.0.0' + this.robotpwd = device.password + this.ipaddress = device.ipaddress ?? device.ip + this.version = device.softwareVer ?? this.platform.version ?? '0.0.0' this.cleanBehaviour = device.cleanBehaviour !== undefined ? device.cleanBehaviour : 'everywhere' this.mission = device.mission || { pmap_id: 'local' } this.stopBehaviour = device.stopBehaviour !== undefined ? device.stopBehaviour : 'home' - this.idlePollIntervalMillis = (device.idleWatchInterval * 60_000) || 900_000 - + this.idlePollIntervalMillis = device.idleWatchInterval ? (device.idleWatchInterval * 60_000) : config.idleWatchInterval ? (config.idleWatchInterval * 60_000) : 900_000 const showDockAsContactSensor = device.dockContactSensor === undefined ? true : device.dockContactSensor const showRunningAsContactSensor = device.runningContactSensor const showBinStatusAsContactSensor = device.binContactSensor @@ -299,6 +312,26 @@ export default class RoombaAccessory implements AccessoryPlugin { this.startPolling() } + private serialNum(device: Robot & DeviceConfig) { + let deviceInfo: DeviceInfo | undefined + let serialNumber: string + const serialNum = device.ipaddress ?? device.ip + if (device.info) { + deviceInfo = device.info + if (device.info.serialNum) { + serialNumber = device.info.serialNum + return { serialNumber, deviceInfo } + } else { + serialNumber = serialNum + return { serialNumber, deviceInfo } + } + } else { + deviceInfo = undefined + serialNumber = serialNum + return { serialNumber, deviceInfo } + } + } + public identify() { this.log.info('Identify requested') this.connect(async (error, roomba) => { @@ -488,6 +521,7 @@ export default class RoombaAccessory implements AccessoryPlugin { /* Use the current Promise, if possible, so we share the connected Roomba instance, whether it is already connected, or when it becomes connected. */ + this.log.debug('currentRoombaPromise: %s', this._currentRoombaPromise ? 'yes' : 'no') const promise = this._currentRoombaPromise || this.connectedRoomba() this._currentRoombaPromise = promise diff --git a/src/index.ts b/src/index.ts index ae03929..4cb6e39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,3 @@ -/* - * index.ts: homebridge-roomba. - */ import type { API } from 'homebridge' import RoombaPlatform from './platform.js' diff --git a/src/lastcommand.ts b/src/lastcommand.ts index 87fcfad..f9de811 100644 --- a/src/lastcommand.ts +++ b/src/lastcommand.ts @@ -1,10 +1,13 @@ -/* eslint-disable no-console */ +import type { Logging } from 'homebridge' + import process from 'node:process' import dorita980 from 'dorita980' +const logger = console as unknown as Logging + if (!process.argv[4]) { - console.log('Usage: npm run getlastcommand ') + logger.error('Usage: npm run getlastcommand ') process.exit() } @@ -21,8 +24,8 @@ type RobotState = dorita980.RobotState & { lastCommand?: { regions: { regionId: function init() { myRobotViaLocal.getRobotState(['lastCommand']) .then((result: RobotState) => { - console.log('lastCommand:', result.lastCommand, ', regionsDetails:', result.lastCommand?.regions) + logger.info('lastCommand:', result.lastCommand, ', regionsDetails:', result.lastCommand?.regions) myRobotViaLocal.end() }) - .catch(console.log) + .catch(logger.error) } diff --git a/src/platform.ts b/src/platform.ts index ae2e250..1ba90c8 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,30 +1,38 @@ -import type { API, Characteristic, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, Service } from 'homebridge' +import type { API, Characteristic, DynamicPlatformPlugin, Logging, PlatformAccessory, Service } from 'homebridge' -import type { DeviceConfig, RoombaPlatformConfig } from './types.js' +import type { DeviceInfo, Robot } from './roomba.js' +import type { DeviceConfig, RoombaPlatformConfig } from './settings.js' import { readFileSync } from 'node:fs' import RoombaAccessory from './accessory.js' +import { getRoombas } from './roomba.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 log!: Logging private config: RoombaPlatformConfig private readonly accessories: Map = new Map() - version: string + version!: string public constructor(log: Logging, config: RoombaPlatformConfig, api: API) { this.Service = api.hap.Service this.Characteristic = api.hap.Characteristic - this.api = api this.config = config const debug = !!config.debug + try { + this.verifyConfig() + log.debug('Configuration:', JSON.stringify(this.config, null, 2)) + } catch (e: any) { + log.error('Error in configuration:', e.message ?? e) + return + } + this.log = !debug ? log : Object.assign(log, { debug: (message: string, ...parameters: unknown[]) => { log.info(`DEBUG: ${message}`, ...parameters) } }) @@ -36,62 +44,76 @@ export default class RoombaPlatform implements DynamicPlatformPlugin { }) } + private verifyConfig() { + if (this.config.disableDiscovery === undefined) { + this.config.disableDiscovery = false + } + } + 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() + private async discoveryMethod(): Promise { + if (this.config.email && this.config.password) { + const robots: Robot[] = await getRoombas(this.config.email, this.config.password, this.log, this.config) + return robots.map((robot) => { + const deviceConfig = this.config.devices?.find(device => device.blid === robot.blid) || {} + return { + ...robot, + ...deviceConfig, + } as any + }) + } else if (this.config.devices) { + return this.config.devices.map(device => ({ + ...device, + })) + } else { + this.log.error('No configuration provided for devices.') + return [] + } + } + + private async discoverDevices(): Promise { + const devices: Robot[] & DeviceConfig[] = await this.discoveryMethod() 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.debug('existingAccessory device: %s', JSON.stringify(device)) this.log.debug('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, device, this.config, this.api) - - // 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); + const { serialNumber, deviceInfo } = this.serialNum(device) + existingAccessory.context.serialNumber = serialNumber + existingAccessory.context.deviceInfo = deviceInfo + existingAccessory.context.model = device.model + existingAccessory.context.firmwareRevision = device.softwareVer ?? this.version ?? '0.0.0' + this.api.updatePlatformAccessories([existingAccessory]) + new RoombaAccessory(this, existingAccessory, this.log, { + ...device, + }, this.config, this.api) } else { - // the accessory does not yet exist, so we need to create it + this.log.debug('accessory device: %s', JSON.stringify(device)) 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, device, this.config, this.api) - - // link the accessory to your platform + const { serialNumber, deviceInfo } = this.serialNum(device) + accessory.context.serialNumber = serialNumber + accessory.context.deviceInfo = deviceInfo + accessory.context.model = device.model + accessory.context.firmwareRevision = device.softwareVer ?? this.version ?? '0.0.0' + new RoombaAccessory(this, accessory, this.log, { + ...device, + }, this.config, this.api) 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)) { @@ -105,18 +127,26 @@ export default class RoombaPlatform implements DynamicPlatformPlugin { } } - private getDevicesFromConfig(): DeviceConfig[] { - return this.config.devices || [] + private serialNum(device: Robot & DeviceConfig) { + let deviceInfo: DeviceInfo | undefined + let serialNumber: string + const serialNum = device.ipaddress ?? device.ip + if (device.info) { + deviceInfo = device.info + if (device.info.serialNum) { + serialNumber = device.info.serialNum + return { serialNumber, deviceInfo } + } else { + serialNumber = serialNum + return { serialNumber, deviceInfo } + } + } else { + deviceInfo = undefined + serialNumber = serialNum + return { serialNumber, deviceInfo } + } } - /** - * Retrieves the version of the plugin from the package.json file. - * - * This method reads the package.json file located in the parent directory, - * parses its content to extract the version, and logs the version using the debug logger. - * - * @returns {string} The version. - */ private getVersion(): string { const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) this.log.debug(`Plugin Version: ${version}`) diff --git a/src/roomba.ts b/src/roomba.ts new file mode 100644 index 0000000..673cbcb --- /dev/null +++ b/src/roomba.ts @@ -0,0 +1,399 @@ +/* eslint-disable style/indent */ +import type { IncomingMessage } from 'node:http' + +import type { Logger } from 'homebridge' + +import type { RoombaPlatformConfig } from './settings.js' + +import { Buffer } from 'node:buffer' +import * as dgram from 'node:dgram' +import * as https from 'node:https' + +export async function getRoombas(email: string, password: string, log: Logger, config: RoombaPlatformConfig): Promise { + let robots: Robot[] = [] + + if (config.disableDiscovery) { + log.info('Using manual discovery as per config') + robots = config.roombas || [] + } else { + log.info('Logging into iRobot...') + + try { + const credentials = await getCredentials(email, password) + robots = await iRobotLogin(credentials) + log.debug('robots:', JSON.stringify(robots)) + } catch (e: any) { + log.error('Failed to login to iRobot, see below for details') + log.error(e.message ?? e) + } + } + + // Extract the key from the JSON object and set it as the blid if not provided or if blid is 0 + for (const key in robots) { + if (Object.prototype.hasOwnProperty.call(robots, key)) { + const robot = robots[key] + if (!robot.blid || robot.blid === '0') { + robot.blid = key + log.debug(`Set blid for robot ${robot.name} to ${robot.blid}`) + } + } + } + + // Ensure robots is an array + if (!Array.isArray(robots)) { + log.debug('Converting robots object to array') + robots = Object.values(robots) + } + + log.debug('Processed robots:', JSON.stringify(robots)) + + const goodRoombas: Robot[] = [] + const badRoombas: Robot[] = [] + + for (const robot of robots) { + if (!config.disableDiscovery) { + log.info('roomba name:', robot.name, 'blid:', robot.blid, 'password:', robot.password) + if (!robot.name || !robot.blid || !robot.password) { + log.error('Skipping configuration for roomba:', robot.name, 'due to missing name, blid or password') + continue + } + + log.info('Configuring roomba:', robot.name) + + try { + const robotIP = await getIP(robot.blid) + robot.ip = robotIP.ip + robot.model = getModel(robotIP.sku) + robot.multiRoom = getMultiRoom(robot.model) + robot.info = robotIP + goodRoombas.push(robot) + } catch (e: any) { + log.error('Failed to connect roomba:', robot.name, 'with error:', e.message ?? e) + log.error('This usually happens if the Roomba is not on the same network as Homebridge, or the Roomba is not reachable from the network') + badRoombas.push(robot) + } + } else { + log.info('Skipping configuration for roomba:', robot.name, 'due to config') + } + } + + for (const roomba of badRoombas) { + log.warn('Not creating an accessory for unreachable Roomba:', roomba.name) + } + + return goodRoombas +} + +function getModel(sku: string): string { + switch (sku.charAt(0)) { + case 'j': + case 'i': + case 's': + return sku.substring(0, 2) + case 'R': + return sku.substring(1, 4) + default: + return sku + } +} + +function getMultiRoom(model: string): boolean { + switch (model.charAt(0)) { + case 's': + case 'j': + return Number.parseInt(model.charAt(1)) > 4 + case 'i': + return Number.parseInt(model.charAt(1)) > 2 + case 'm': + return Number.parseInt(model.charAt(1)) === 6 + default: + return false + } +} + +export interface Robot { + name: string + blid: string + sku?: string + password: string + autoConfig?: boolean + ip: string + model: string + multiRoom: boolean + softwareVer?: string + info: DeviceInfo +} + +export interface DeviceInfo { + serialNum?: string + ver?: string + hostname?: string + robotname?: string + robotid?: string + mac?: string + sw: string + sku?: string + nc?: number + proto?: string + cap?: object +} + +async function getIP(blid: string, attempt: number = 1): Promise { + return new Promise((resolve, reject) => { + if (attempt > 5) { + reject(new Error(`No Roomba Found With Blid: ${blid}`)) + return + } + + const server = dgram.createSocket('udp4') + + server.on('error', (err) => { + reject(err) + }) + + server.on('message', (msg) => { + try { + const parsedMsg = JSON.parse(msg.toString()) + const [prefix, id] = parsedMsg.hostname.split('-') + if ((prefix === 'Roomba' || prefix === 'iRobot') && id === blid) { + server.close() + resolve(parsedMsg) + } + } catch (e: any) { } + }) + + server.on('listening', () => { + setTimeout(() => { + getIP(blid, attempt + 1).then(resolve).catch(reject) + }, 5000) + }) + + server.bind(() => { + const message = Buffer.from('irobotmcs') + server.setBroadcast(true) + server.send(message, 0, message.length, 5678, '255.255.255.255') + }) + }) +} + +async function getCredentials(email: string, password: string): Promise { + return new Promise((resolve, reject) => { + const apiKey = '3_rWtvxmUKwgOzu3AUPTMLnM46lj-LxURGflmu5PcE_sGptTbD-wMeshVbLvYpq01K' + const gigyaURL = new URL('https://accounts.us1.gigya.com/accounts.login') + gigyaURL.search = new URLSearchParams({ + apiKey, + targetenv: 'mobile', + loginID: email, + password, + format: 'json', + targetEnv: 'mobile', + }).toString() + + const gigyaLoginOptions = { + hostname: gigyaURL.hostname, + path: gigyaURL.pathname + gigyaURL.search, + method: 'POST', + headers: { + Connection: 'close', + }, + } + + const req = https.request(gigyaLoginOptions, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + gigyaLoginResponse(null, res, JSON.parse(data), resolve, reject) + }) + }) + + req.on('error', (error) => { + gigyaLoginResponse(error, undefined, undefined, resolve, reject) + }) + + req.end() + }) +} + +function gigyaLoginResponse(error: Error | null, response?: IncomingMessage, body?: any, resolve?: (value: any) => void, reject?: (reason?: any) => void): void { + if (error) { + reject?.(new Error(`Fatal error logging into Gigya API. Please check your credentials or Gigya API Key. ${error.message}`)) + return + } + + if (response?.statusCode !== undefined && [401, 403].includes(response.statusCode)) { + reject?.(new Error(`Authentication error. Check your credentials. ${response.statusCode}`)) + } else if (response && response.statusCode === 400) { + reject?.(new Error(`Error logging into Gigya API. ${response.statusCode}`)) + } else if (response && response.statusCode === 200) { + gigyaSuccess(body, resolve, reject) + } else { + reject?.(new Error('Unexpected response. Checking again...')) + } +} + +function gigyaSuccess(body: any, resolve?: (value: any) => void, reject?: (reason?: any) => void): void { + if (body.statusCode === 403) { + reject?.(new Error(`Authentication error. Please check your credentials. ${body.statusCode}`)) + return + } + if (body.statusCode === 400) { + reject?.(new Error(`Error logging into Gigya API. ${body.statusCode}`)) + return + } + if (body.statusCode === 200 && body.errorCode === 0 && body.UID && body.UIDSignature && body.signatureTimestamp && body.sessionInfo && body.sessionInfo.sessionToken) { + resolve?.(body) + } else { + reject?.(new Error(`Error logging into iRobot account. Missing fields in login response. ${body.statusCode}`)) + } +} + +async function iRobotLogin(body: any, server: number = 1): Promise { + return new Promise((resolve, reject) => { + const iRobotLoginOptions = { + hostname: `unauth${server}.prod.iot.irobotapi.com`, + path: '/v2/login', + method: 'POST', + headers: { + 'Connection': 'close', + 'Content-Type': 'application/json', + }, + } + + const req = https.request(iRobotLoginOptions, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + try { + iRobotLoginResponse(null, res, JSON.parse(data), resolve, reject) + } catch (e: any) { + if (server === 1) { + iRobotLogin(body, 2).then(resolve).catch(reject) + } else { + iRobotLoginResponse(e.message ?? e, undefined, undefined, resolve, reject) + } + } + }) + }) + + req.on('error', (error) => { + iRobotLoginResponse(error, undefined, undefined, resolve, reject) + }) + + req.write(JSON.stringify({ + app_id: 'ANDROID-C7FB240E-DF34-42D7-AE4E-A8C17079A294', + assume_robot_ownership: 0, + gigya: { + signature: body.UIDSignature, + timestamp: body.signatureTimestamp, + uid: body.UID, + }, + })) + + req.end() + }) +} + +function iRobotLoginResponse(error: Error | null, _response?: IncomingMessage, body?: any, resolve?: (value: any) => void, reject?: (reason?: any) => void): void { + if (error) { + reject?.(new Error(`Fatal error logging into iRobot account. Please check your credentials or API Key. ${error.message}`)) + return + } + if (body && body.robots) { + resolve?.(body.robots) + } else { + reject?.(new Error(`Fatal error logging into iRobot account. Please check your credentials or API Key. ${body?.statusCode}`)) + } +} + +declare module 'dorita980' { + export class RoombaLocal { + constructor(username: string, password: string, ip: string, version?: 2 | 3, options?: object | number) + on(event: 'connect', listener: () => void): this + on(event: 'reconnect', listener: () => void): this + on(event: 'close', listener: () => void): this + on(event: 'offline', listener: () => void): this + on(event: 'update', listener: (data: Data) => void): this + on(event: 'mission', listener: (data: cleanMissionStatus) => void): this + on(event: 'error', listener: (error: Error) => void): this + on(event: 'state', listener: (data: unknown) => void): this + removeAllListeners(event?: string | symbol): this + end(): void + getTime(): Promise + getBbrun(): Promise + getLangs(): Promise + getSys(): Promise + getWirelessLastStatus(): Promise + getWeek(): Promise + getPreferences(waitForFields?: string[]): this + getRobotState(waitForFields?: string[]): this + getMission(calwaitForFields?: string[]): this + getBasicMission(waitForFields?: string[]): this + getWirelessConfig(): Promise + getWirelessStatus(): Promise + getCloudConfig(): Promise + getSKU(): Promise + start(): Promise<{ ok: null }> + clean(): Promise<{ ok: null }> + cleanRoom(callback?: (args: any) => Promise<{ ok: null }>): this + pause(): Promise<{ ok: null }> + stop(): Promise<{ ok: null }> + resume(): Promise<{ ok: null }> + dock(): Promise<{ ok: null }> + find(): Promise<{ ok: null }> + evac(): Promise<{ ok: null }> + train(): Promise<{ ok: null }> + setWeek(callback?: (args: any) => Promise<{ ok: null }>): this + setPreferences(callback?: (args: any) => Promise<{ ok: null }>): this + setCarpetBoostAuto(): Promise<{ ok: null }> + setCarpetBoostPerformance(): Promise<{ ok: null }> + setCarpetBoostEco(): Promise<{ ok: null }> + setEdgeCleanOn(): Promise<{ ok: null }> + setEdgeCleanOff(): Promise<{ ok: null }> + setCleaningPassesAuto(): Promise<{ ok: null }> + setCleaningPassesOne(): Promise<{ ok: null }> + setCleaningPassesTwo(): Promise<{ ok: null }> + setAlwaysFinishOn(): Promise<{ ok: null }> + setAlwaysFinishOff(): Promise<{ ok: null }> + } + + export interface fullRobotState { } + + interface cleanMissionStatus { + cleanMissionStatus: { + cycle: string + phase: string + expireM: number + rechrgM: number + error: number + notReady: number + mssnM: number + sqft: number + initiator: string + nMssn: number + } + pose: { theta: number, point: { x: number, y: number } } + } + + interface Data { + state: { + reported: { + soundVer: string + uiSwVer: string + navSwVer: string + wifiSwVer: string + mobilityVer: string + bootloaderVer: string + umiVer: string + softwareVer: string + } + } + } +} diff --git a/src/settings.ts b/src/settings.ts index 4a320ef..2b91ec6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,3 +1,7 @@ +import type { RobotMission } from 'dorita980' +import type { PlatformConfig } from 'homebridge' + +import type { Robot } from './roomba.js' /** * This is the name of the platform that users will use to register the plugin in the Homebridge config.json */ @@ -7,3 +11,34 @@ export const PLATFORM_NAME = 'Roomba' * This must match the name of your plugin as defined the package.json */ export const PLUGIN_NAME = '@homebridge-plugins/homebridge-roomba' + +export interface RoombaPlatformConfig extends PlatformConfig { + devices: DeviceConfig[] + disableDiscovery?: boolean + idleWatchInterval?: number + debug?: boolean +} + +export interface DeviceConfig extends Robot { + 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 +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e4b89ce..0000000 --- a/src/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { RobotMission } from 'dorita980' -import type { PlatformConfig } from 'homebridge' - -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 -} -export interface RoombaPlatformConfig extends PlatformConfig { - devices: DeviceConfig[] - debug?: boolean -} diff --git a/tsconfig.json b/tsconfig.json index 09f613f..415aee2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ { "compilerOptions": { "target": "ES2022", - "module": "ES2022", "lib": [ "DOM", "ES2022" ], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", "rootDir": "src", + "module": "ES2022", + "moduleResolution": "bundler", "strict": true, - "esModuleInterop": true, "noImplicitAny": false, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "sourceMap": true, "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true }, "include": [ "src"