diff --git a/config.schema.json b/config.schema.json index abd76ac..327af02 100644 --- a/config.schema.json +++ b/config.schema.json @@ -14,6 +14,18 @@ "default": "NoIP", "required": true }, + "email": { + "type": "string", + "title": "iRobot Account Email", + "description": "The email address you use to log into the iRobot Home app.", + "required": true + }, + "Password": { + "type": "string", + "title": "iRobot Account Password", + "description": "The password you use to log into the iRobot Home app.", + "required": true + }, "devices": { "type": "array", "items": { @@ -109,7 +121,6 @@ "type": "number", "title": "Clean rooms in order", "default": 1, - "required": true, "oneOf": [ { "title": "Yes", @@ -131,7 +142,6 @@ "pmap_id": { "type": "string", "title": "Pmap Id", - "required": true, "condition": { "functionBody": "return (model.devices && model.devices[arrayIndices].cleanBehaviour === 'rooms');" } @@ -144,14 +154,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", @@ -223,6 +231,16 @@ } }, "layout": [ + { + "type": "fieldset", + "title": "iRobot Account", + "expandable": true, + "expanded": false, + "items": [ + "email", + "Password" + ] + }, { "key": "devices", "notitle": false, diff --git a/src/accessory.ts b/src/accessory.ts index 4c83130..a893bf2 100644 --- a/src/accessory.ts +++ b/src/accessory.ts @@ -1,6 +1,8 @@ import type { RobotMission, RobotState, Roomba } from 'dorita980' import type { AccessoryPlugin, API, CharacteristicGetCallback, CharacteristicSetCallback, CharacteristicValue, Logging, PlatformAccessory, Service, WithUUID } from 'homebridge' +import type { Robot } from './roomba/getRoombas.js' + import type RoombaPlatform from './platform.js' import type { DeviceConfig, RoombaPlatformConfig } from './types.js' @@ -145,14 +147,22 @@ 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.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 diff --git a/src/platform.ts b/src/platform.ts index ae2e250..06de802 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,16 +1,16 @@ -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 { readFileSync } from 'node:fs' import RoombaAccessory from './accessory.js' +import { getRoombas, Robot } from './roomba/getRoombas.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: RoombaPlatformConfig @@ -20,7 +20,6 @@ export default class RoombaPlatform implements DynamicPlatformPlugin { 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 @@ -38,60 +37,55 @@ export default class RoombaPlatform implements DynamicPlatformPlugin { 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 { + const robots: Robot[] = await getRoombas(this.config.email, this.config.password, this.log) + return robots.map(robot => { + const deviceConfig = this.config.devices?.find(device => device.blid === robot.blid) || {} + return { + ...robot, + ...deviceConfig, + cleanBehaviour: this.config.cleanBehaviour, + stopBehaviour: this.config.stopBehaviour, + idleWatchInterval: this.config.idleWatchInterval + } + }) + } + + private async discoverDevices(): Promise { + const devices: Robot[] = 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('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); + new RoombaAccessory(this, existingAccessory, this.log, { + ...device, + cleanBehaviour: this.config.cleanBehaviour, + stopBehaviour: this.config.stopBehaviour, + idleWatchInterval: this.config.idleWatchInterval + }, this.config, this.api) } 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, device, this.config, this.api) - - // link the accessory to your platform + new RoombaAccessory(this, accessory, this.log, { + ...device, + cleanBehaviour: this.config.cleanBehaviour, + stopBehaviour: this.config.stopBehaviour, + idleWatchInterval: this.config.idleWatchInterval + }, 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 +99,6 @@ export default class RoombaPlatform implements DynamicPlatformPlugin { } } - private getDevicesFromConfig(): DeviceConfig[] { - return this.config.devices || [] - } - - /** - * 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/getRoombaCredentials.ts b/src/roomba/getRoombaCredentials.ts new file mode 100644 index 0000000..f42c9de --- /dev/null +++ b/src/roomba/getRoombaCredentials.ts @@ -0,0 +1,182 @@ +/* eslint-disable style/indent */ +import type { IncomingMessage } from 'node:http' +import type { Robot } from './getRoombas' +import https from 'node:https' +import process from 'node:process' + +interface GigyaResponseBody { + statusCode: number + errorCode: number + UID: string + UIDSignature: string + signatureTimestamp: string + sessionInfo: { + sessionToken: string + } +} + +interface IRobotResponseBody { + robots: Record +} + +export async function getRoombaCredentials(username: string, password: string, apiKey: string = '3_rWtvxmUKwgOzu3AUPTMLnM46lj-LxURGflmu5PcE_sGptTbD-wMeshVbLvYpq01K'): Promise { + return new Promise((resolve, reject) => { + const gigyaURL = new URL('https://accounts.us1.gigya.com/accounts.login') + gigyaURL.search = new URLSearchParams({ + apiKey, + targetenv: 'mobile', + loginID: username, + 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', () => { + loginGigyaResponseHandler(null, res, JSON.parse(data) as GigyaResponseBody) + }) + }) + + req.on('error', (error) => { + loginGigyaResponseHandler(error, null, null) + }) + + req.end() + + function loginGigyaResponseHandler(error: unknown, response: IncomingMessage | null, body: GigyaResponseBody | null) { + if (error) { + reject(new Error('Fatal error logging into Gigya API. Please check your credentials or Gigya API Key.')) + return + } + + if (response && response.statusCode && [401, 403].includes(response.statusCode)) { + reject(new Error('Authentication error. Check your credentials.')) + } else if (response!.statusCode === 400) { + reject(new Error('Bad request.')) + } else if (response!.statusCode === 200) { + handleGigyaSuccess(body!) + } else { + reject(new Error('Unexpected response.')) + } + } + + function handleGigyaSuccess(body: GigyaResponseBody) { + if (body.statusCode === 403) { + reject(new Error('Authentication error. Please check your credentials.')) + return + } + if (body.statusCode === 400) { + reject(new Error('Error logging into Gigya API.')) + return + } + if (body.statusCode === 200 && body.errorCode === 0 && body.UID && body.UIDSignature && body.signatureTimestamp && body.sessionInfo && body.sessionInfo.sessionToken) { + loginToIRobot(body) + } else { + reject(new Error('Error logging into iRobot account. Missing fields in login response.')) + } + } + + function loginToIRobot(body: GigyaResponseBody, server: number = 1) { + 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 { + loginIrobotResponseHandler(null, res, JSON.parse(data) as IRobotResponseBody) + } catch (e) { + if (server === 1) { + loginToIRobot(body, 2) + } else { + loginIrobotResponseHandler(e as IRobotError, res, null) + } + } + }) + }) + + req.on('error', (error) => { + loginIrobotResponseHandler(error, null, null) + }) + + 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() + } + + interface IRobotError { + message: string + stack?: string + } + + interface IRobotResponse { + robots: Record + } + + function loginIrobotResponseHandler(error: IRobotError | null, _response: IncomingMessage | null, body: IRobotResponse | null) { + if (error) { + console.error('Fatal error logging into iRobot account. Please check your credentials or API Key.', error) + process.exit(0) + } + if (body && body.robots) { + resolve(Object.entries(body.robots).map(([id, robot]) => ({ + name: robot.name, + blid: id, + password: robot.password, + ip: robot.ip, + model: '', // Add appropriate value + robotpwd: '', // Add appropriate value + ipaddress: robot.ip, + multiRoom: false, // Add appropriate value + info: { sw: '' } // Add appropriate value + }))) + } else { + console.error('Fatal error logging into iRobot account. Please check your credentials or API Key.', body) + process.exit(0) + } + } + }) +} diff --git a/src/roomba/getRoombaIP.ts b/src/roomba/getRoombaIP.ts new file mode 100755 index 0000000..6282d0d --- /dev/null +++ b/src/roomba/getRoombaIP.ts @@ -0,0 +1,50 @@ +import { Buffer } from 'node:buffer' +import dgram from 'node:dgram' + +export async function getRoombaIP(blid: string, attempt = 1): Promise<{ hostname: string, ip: string }> { + if (!blid) { + throw new Error('No blid supplied') + } + + if (attempt > 5) { + throw new Error(`No Roomba Found With Blid: ${blid}`) + } + + return new Promise((resolve, reject) => { + const server = dgram.createSocket('udp4') + + server.on('error', (err) => { + server.close() + reject(err) + }) + + server.on('message', (msg) => { + try { + const parsedMsg = JSON.parse(msg.toString()) + if (parsedMsg.hostname && parsedMsg.ip && ((parsedMsg.hostname.split('-')[0] === 'Roomba') || (parsedMsg.hostname.split('-')[0] === 'iRobot'))) { + if (parsedMsg.hostname.split('-')[1] === blid) { + server.close() + resolve({ hostname: parsedMsg.hostname, ip: parsedMsg.ip }) + } + } + } catch (e) { } + }) + + server.on('listening', () => { + setTimeout(async () => { + try { + const result = await getRoombaIP(blid, attempt + 1) + resolve(result) + } catch (err) { + reject(err) + } + }, 5000) + }) + + server.bind(() => { + const message = Buffer.from('irobotmcs') + server.setBroadcast(true) + server.send(message, 0, message.length, 5678, '255.255.255.255') + }) + }) +} diff --git a/src/roomba/getRoombas.ts b/src/roomba/getRoombas.ts new file mode 100755 index 0000000..404bab2 --- /dev/null +++ b/src/roomba/getRoombas.ts @@ -0,0 +1,104 @@ +/* eslint-disable style/indent */ +import type { Logger, PlatformConfig } from 'homebridge' + +import { getRoombaCredentials } from './getRoombaCredentials.js' +import { getRoombaIP } from './getRoombaIP.js' + +export async function getRoombas(email: string, password: string, log: Logger): Promise { + let robots: Robot[] = [] + + log.info('Logging into iRobot...') + + try { + const Robots = await getRoombaCredentials(email, password) + robots = JSON.parse(Robots as unknown as string) + log.debug(Robots as unknown as string) + } catch (e: any) { + log.error('Failed to login to iRobot, see below for details', e.message ?? e) + } + + const goodRoombas: Robot[] = [] + const badRoombas: Robot[] = [] + + for (const robot of robots) { + if (!robot.name || !robot.blid || !robot.robotpwd) { + log.error('Skipping configuration for roomba:', robot.name, 'due to missing name, blid or password') + continue + } + + log.info('Configuring roomba:', robot.name) + + try { + const robotInfo = await getRoombaIP(robot.blid) as unknown as { ip?: string, sku: string, sw: string } + if (robotInfo.ip) { + robot.ipaddress = robotInfo.ip + } else { + log.error('IP address not found for roomba:', robot.name) + continue + } + delete robotInfo.ip + robot.model = getModel(robotInfo.sku) + robot.multiRoom = getMultiRoom(robot.model) + robot.info = robotInfo + + goodRoombas.push(robot) + } catch (e: any) { + log.error('Failed to connect roomba:', robot.name, 'with error:', e.message) + 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) + } + } + + 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) { + 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 + model: string + blid: string + robotpwd: string + ipaddress: string + multiRoom: boolean + info: { + serialNum?: string + ver?: string + hostname?: string + robotname?: string + robotid?: string + mac?: string + sw: string + sku?: string + nc?: number + proto?: string + cap?: unknown + } +} diff --git a/src/types.ts b/src/types.ts index e4b89ce..9337121 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,8 @@ import type { RobotMission } from 'dorita980' import type { PlatformConfig } from 'homebridge' +import { Robot } from './roomba/getRoombas' -export interface DeviceConfig { +export interface DeviceConfig extends Robot { name: string model: string serialnum?: string