diff --git a/accessories/koboldVacuumRobot.js b/archive/accessories/koboldVacuumRobot.js similarity index 100% rename from accessories/koboldVacuumRobot.js rename to archive/accessories/koboldVacuumRobot.js diff --git a/characteristics/spotHeight.js b/archive/characteristics/spotHeight.js similarity index 100% rename from characteristics/spotHeight.js rename to archive/characteristics/spotHeight.js diff --git a/characteristics/spotRepeat.js b/archive/characteristics/spotRepeat.js similarity index 100% rename from characteristics/spotRepeat.js rename to archive/characteristics/spotRepeat.js diff --git a/characteristics/spotWidth.js b/archive/characteristics/spotWidth.js similarity index 100% rename from characteristics/spotWidth.js rename to archive/characteristics/spotWidth.js diff --git a/index.js b/archive/index.js similarity index 100% rename from index.js rename to archive/index.js diff --git a/nodemon.json b/nodemon.json index 6dd7df6..6f9b4bc 100644 --- a/nodemon.json +++ b/nodemon.json @@ -4,7 +4,7 @@ ], "ext": "ts", "ignore": [], - "exec": "tsc && homebridge -I -D", + "exec": "tsc", "signal": "SIGTERM", "env": { "NODE_OPTIONS": "--trace-warnings" diff --git a/package.json b/package.json index c1c70b6..9ca4a91 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "main": "dist/index.js", "scripts": { "lint": "eslint src/**.ts --max-warnings=0", - "watch": "npm run build && npm link && nodemon", - "build": "rimraf ./dist && tsc" + "build": "rimraf ./dist && tsc", + "watch": "npm run build && npm link && nodemon" }, "author": { "name": "Luis R.", diff --git a/src/accessories/koboldVacuumRobot.ts b/src/accessories/koboldVacuumRobot.ts new file mode 100644 index 0000000..e83812f --- /dev/null +++ b/src/accessories/koboldVacuumRobot.ts @@ -0,0 +1,648 @@ +import {CharacteristicValue, Logger, PlatformAccessory, PlatformAccessoryEvent, PlatformConfig, Service} from 'homebridge'; +import {HomebridgeKoboldPlatform} from '../homebridgeKoboldPlatform'; +import {Options} from '../models/options'; + +/** + * Platform Accessory + * An instance of this class is created for each accessory your platform registers + * Each accessory may expose multiple services of different service types. + */ +export class KoboldVacuumRobotAccessory +{ + // Homebridge + private log: Logger; + private batteryService: Service; + private readonly cleanService: Service | null; + private readonly findMeService: Service | null; + private readonly goToDockService: Service | null; + private readonly dockStateService: Service | null; + private readonly binFullService: Service | null; + private readonly ecoService: Service | null; + private readonly noGoLinesService: Service | null; + private readonly extraCareService: Service | null; + private readonly scheduleService: Service | null; + private readonly spotCleanService: Service | null; + + // Context + private robot: any; + private readonly options: Options; + + // Config + private readonly backgroundUpdateInterval: number; + private readonly prefix: boolean; + private readonly availableServices: string[]; + + // Transient + private isSpotCleaning: boolean; + private timer: any; + + /** + * These are just used to create a working example + * You should implement your own code to track the state of your accessory + */ + + constructor( + private readonly platform: HomebridgeKoboldPlatform, + private readonly accessory: PlatformAccessory, + private readonly config: PlatformConfig) + { + this.log = platform.log; + + this.robot = accessory.context.robot; + this.options = accessory.context.options || new Options(); + + this.backgroundUpdateInterval = KoboldVacuumRobotAccessory.parseBackgroundUpdateInterval(this.config['backgroundUpdate']); + this.prefix = this.config['prefix'] || PREFIX; + this.availableServices = this.config['services'] || SERVICES; + + this.isSpotCleaning = false; + + // Information + this.accessory.getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, "Neato Robotics") + .setCharacteristic(this.platform.Characteristic.Model, this.robot.meta.modelName) + .setCharacteristic(this.platform.Characteristic.SerialNumber, this.robot._serial) + .setCharacteristic(this.platform.Characteristic.FirmwareRevision, this.robot.meta.firmware) + .setCharacteristic(this.platform.Characteristic.Name, this.robot.name); + + // Identify + this.accessory.on(PlatformAccessoryEvent.IDENTIFY, () => { + this.robot.findMe(); + + this.robot.getState((error, result) => { + this.log.info("[" + this.robot.name + "] Identified"); + if (error) + { + this.debug(DebugType.INFO, JSON.stringify("Error: " + error)); + } + this.debug(DebugType.INFO, "Status: " + JSON.stringify(result)); + this.debug(DebugType.INFO, + "Config: Background Update Interval: " + this.backgroundUpdateInterval + ", Prefix: " + this.prefix + ", Enabled services: " + JSON.stringify(this.availableServices)); + }); + }); + + // Services + this.cleanService = this.getSwitchService(RobotService.CLEAN_HOUSE); + this.spotCleanService = this.getSwitchService(RobotService.CLEAN_SPOT); + this.goToDockService = this.getSwitchService(RobotService.GO_TO_DOCK); + this.dockStateService = this.getOccupancyService(RobotService.DOCKED) + this.binFullService = this.getOccupancyService(RobotService.BIN_FULL) + this.findMeService = this.getSwitchService(RobotService.FIND_ME); + this.scheduleService = this.getSwitchService(RobotService.SCHEDULE); + this.ecoService = this.getSwitchService(RobotService.ECO); + this.noGoLinesService = this.getSwitchService(RobotService.NOGO_LINES); + this.extraCareService = this.getSwitchService(RobotService.EXTRA_CARE); + this.batteryService = this.accessory.getService(this.platform.Service.Battery) || this.accessory.addService(this.platform.Service.Battery) + + if (this.cleanService) + { + this.cleanService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setCleanHouse.bind(this)) + .onGet(this.getCleanHouse.bind(this)); + } + if (this.spotCleanService) + { + this.spotCleanService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setSpotClean.bind(this)) + .onGet(this.getSpotClean.bind(this)); + } + if (this.goToDockService) + { + this.goToDockService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setGoToDock.bind(this)) + .onGet(this.getGoToDock.bind(this)); + } + if (this.dockStateService) + { + this.dockStateService.getCharacteristic(this.platform.Characteristic.OccupancyDetected) + .onGet(this.getDocked.bind(this)); + } + if (this.binFullService) + { + this.binFullService.getCharacteristic(this.platform.Characteristic.OccupancyDetected) + .onGet(this.getBinFull.bind(this)); + } + if (this.findMeService) + { + this.findMeService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setFindMe.bind(this)) + .onGet(this.getFindMe.bind(this)); + } + if (this.scheduleService) + { + this.scheduleService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setSchedule.bind(this)) + .onGet(this.getSchedule.bind(this)); + } + if (this.ecoService) + { + this.ecoService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setEco.bind(this)) + .onGet(this.getEco.bind(this)); + } + if (this.noGoLinesService) + { + this.noGoLinesService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setNoGoLines.bind(this)) + .onGet(this.getNoGoLines.bind(this)); + } + if (this.extraCareService) + { + this.extraCareService.getCharacteristic(this.platform.Characteristic.On) + .onSet(this.setExtraCare.bind(this)) + .onGet(this.getExtraCare.bind(this)); + } + + // Start background update + this.updateRobotPeriodically().then(() => { + if (!accessory.context.options) + { + this.options.eco = this.robot.eco; + this.options.noGoLines = this.robot.noGoLines; + this.options.extraCare = this.robot.navigationMode == 2; + this.debug(DebugType.INFO, "Options initially set to eco: " + this.options.eco + ", noGoLines: " + this.options.noGoLines + ", extraCare: " + this.options.extraCare); + accessory.context.options = this.options; + } + else + { + this.debug(DebugType.INFO, "Options loaded from cache eco: " + this.options.eco + ", noGoLines: " + this.options.noGoLines + ", extraCare: " + this.options.extraCare); + } + }); + } + + private getSwitchService(serviceName: string) + { + let displayName = this.prefix ? this.robot.name + serviceName : serviceName; + + if (this.availableServices.includes(serviceName)) + { + return this.accessory.getService(displayName) || this.accessory.addService(this.platform.Service.Switch, displayName, serviceName); + } + else + { + if (this.accessory.getService(displayName)) + { + this.accessory.removeService(this.accessory.getService(displayName)); + } + return null; + } + } + + private getOccupancyService(serviceName: string) + { + let displayName = this.prefix ? this.robot.name + serviceName : serviceName; + + if (this.availableServices.includes(serviceName)) + { + return this.accessory.getService(displayName) || this.accessory.addService(this.platform.Service.OccupancySensor, displayName, serviceName); + } + else + { + if (this.accessory.getService(displayName)) + { + this.accessory.removeService(this.accessory.getService(displayName)); + } + return null; + } + } + + private static parseBackgroundUpdateInterval(configValue: any) + { + // Parse as number + let backgroundUpdateInterval = parseInt(configValue) || BACKGROUND_INTERVAL; + + // must be integer and positive + backgroundUpdateInterval = ((backgroundUpdateInterval % 1) !== 0 || backgroundUpdateInterval < 0) ? BACKGROUND_INTERVAL : backgroundUpdateInterval; + + return backgroundUpdateInterval; + } + + async getCleanHouse(): Promise + { + try + { + await this.updateRobot(); + return this.robot.canPause && !this.isSpotCleaning; + } + catch (error) + { + this.log.error("Cannot get cleaning status: " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async setCleanHouse(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set CLEAN HOUSE: " + on); + try + { + await this.updateRobot(); + + // Start + if (on) + { + // Resume cleaning + if (this.robot.canResume) + { + this.debug(DebugType.ACTION, "Resume cleaning"); + await this.robot.resumeCleaning(); + } + // Start cleaning + else if (this.robot.canStart) + { + await this.clean(CleanType.ALL) + } + // Cannot start + else + { + this.debug(DebugType.INFO, "Cannot start, maybe already cleaning (expected)"); + } + } + // Stop + else + { + if (this.robot.canPause) + { + this.debug(DebugType.ACTION, "Pause cleaning"); + await this.robot.pauseCleaning(); + } + else + { + this.debug(DebugType.INFO, "Already paused"); + } + } + } + catch (error) + { + this.log.error("Error setting cleaning to: " + on + ". " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async getSpotClean(): Promise + { + try + { + await this.updateRobot(); + return this.robot.canPause && this.isSpotCleaning; + } + catch (error) + { + this.log.error("Cannot get spot cleaning status: " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async setSpotClean(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set SPOT CLEAN: " + on); + try + { + if (on) + { + await this.clean(CleanType.SPOT) + } + else + { + // TODO stop/pause + } + } + catch (error) + { + this.log.error("Error setting spot cleaning to: " + on + ". " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + getGoToDock() + { + return false; + } + + async setGoToDock(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set GO TO DOCK: " + on); + if (on) + { + await this.updateRobot(); + + setTimeout(() => { + if (this.goToDockService) + { + this.goToDockService.updateCharacteristic(this.platform.Characteristic.On, false); + } + }, 10000); + + try + { + if (this.robot.canPause) + { + this.debug(DebugType.ACTION, "Pause cleaning to go to dock"); + await this.robot.pauseCleaning(); + setTimeout(async () => { + await this.robot.sendToBase(); + }, 1000); + } + else if (this.robot.canGoToBase) + { + this.debug(DebugType.ACTION, "Going to dock"); + await this.robot.sendToBase(); + } + else + { + this.log.warn("[" + this.robot.name + "] Can't go to dock at the moment"); + } + } + catch (error) + { + this.log.error("Error setting go to dock to: " + on + ". " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + } + + async getDocked(): Promise + { + try + { + await this.updateRobot(); + return this.robot.isDocked; + } + catch (error) + { + this.log.error("Cannot get docked status: " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async getBinFull(): Promise + { + try + { + await this.updateRobot(); + return this.robot.isBinFull; + } + catch (error) + { + this.log.error("Cannot get bin full status: " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async getSchedule(): Promise + { + try + { + await this.updateRobot(); + return this.robot.isScheduleEnabled; + } + catch (error) + { + this.log.error("Cannot get schedule status: " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + async setSchedule(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set SCHEDULE: " + on); + try + { + if (on) + { + await this.robot.enableSchedule(); + } + else + { + await this.robot.disableSchedule(); + } + } + catch (error) + { + this.log.error("Error setting schedule to: " + on + ". " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + getEco() + { + return this.options.eco; + } + + setEco(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set ECO: " + on); + this.options.eco = on; + } + + getExtraCare() + { + return this.options.extraCare; + } + + setExtraCare(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set EXTRA CARE: " + on); + this.options.extraCare = on; + } + + getNoGoLines() + { + return this.options.noGoLines; + } + + setNoGoLines(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set NOGO LINES: " + on); + this.options.noGoLines = on; + } + + getFindMe() + { + return false; + } + + async setFindMe(on: CharacteristicValue) + { + this.debug(DebugType.STATUS, "Set FIND ME: " + on); + if (on) + { + this.debug(DebugType.ACTION, "Find me"); + setTimeout(() => { + if (this.findMeService) + { + this.findMeService.updateCharacteristic(this.platform.Characteristic.On, false); + } + }, 1000); + + try + { + await this.robot.findMe(); + } + catch (error) + { + this.log.error(this.robot.name + " ## Cannot start find me. " + error); + throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + } + + async clean(cleanType: CleanType) + { + // Enable shorter background update while cleaning + setTimeout(() => { + this.updateRobotPeriodically(); + }, 60 * 1000); + + this.log.info( + "[" + this.robot.name + "] > Start cleaning with options type: " + CleanType[cleanType] + ", eco: " + this.options.eco + ", noGoLines: " + this.options.noGoLines + ", extraCare: " + + this.options.extraCare); + + try + { + switch (cleanType) + { + case CleanType.ALL: + await this.robot.startCleaning(this.options.eco, this.options.extraCare ? 2 : 1, this.options.noGoLines); + break; + case CleanType.SPOT: + await this.robot.startSpotCleaning(this.options.eco, this.options.spot.width, this.options.spot.height, this.options.spot.repeat, this.options.extraCare ? 2 : 1); + break; + } + } + catch (error) + { + this.log.error("Cannot start cleaning. " + error); + } + } + + async updateRobot() + { + // Data is outdated + if (typeof (this.robot.lastUpdate) === 'undefined' || new Date().getTime() - this.robot.lastUpdate > 2000) + { + this.robot.lastUpdate = new Date().getTime(); + try + { + this.robot.getState((error, result) => { + this.isSpotCleaning = result != null && result.action == 2; + + // Battery + this.batteryService.updateCharacteristic(this.platform.Characteristic.BatteryLevel, this.robot.charge); + this.batteryService.updateCharacteristic(this.platform.Characteristic.ChargingState, this.robot.isCharging); + }); + } + catch (error) + { + this.log.error("Cannot update robot " + this.robot.name + ". Check if robot is online. " + error); + return false; + } + } + } + + async updateRobotPeriodically() + { + this.debug(DebugType.INFO, "Performing background update") + + await this.updateRobot() + await this.updateCharacteristics(); + + // Clear any other overlapping timers for this robot + clearTimeout(this.timer); + + // Tell all accessories of this robot (mainAccessory and roomAccessories) that updated robot data is available + // this.robot.mainAccessory.updated(); + // this.robot.roomAccessories.forEach(accessory => { + // accessory.updated(); + // }); + + // Periodic refresh interval set in config + let interval; + if (this.robot.canPause) + { + interval = 1; + } + else + { + interval = this.backgroundUpdateInterval; + } + + this.debug(DebugType.INFO, "Background update done. Next update in " + interval + " minute" + (interval == 1 ? "" : "s") + ((this.robot.canPause) ? ", robot is currently cleaning." : ".")); + this.timer = setTimeout(this.updateRobotPeriodically.bind(this), interval * 60 * 1000); + } + + async updateCharacteristics() + { + if (this.cleanService) + { + this.cleanService.updateCharacteristic(this.platform.Characteristic.On, await this.getCleanHouse()); + } + if (this.spotCleanService) + { + this.spotCleanService.updateCharacteristic(this.platform.Characteristic.On, await this.getSpotClean()); + } + if (this.goToDockService) + { + this.goToDockService.updateCharacteristic(this.platform.Characteristic.On, await this.getGoToDock()); + } + if (this.dockStateService) + { + this.dockStateService.updateCharacteristic(this.platform.Characteristic.OccupancyDetected, await this.getDocked()); + } + if (this.binFullService) + { + this.binFullService.updateCharacteristic(this.platform.Characteristic.OccupancyDetected, await this.getBinFull()); + } + if (this.scheduleService) + { + this.scheduleService.updateCharacteristic(this.platform.Characteristic.On, await this.getSchedule()); + } + } + + private debug(debugType: DebugType, message: String) + { + switch (debugType) + { + case DebugType.ACTION: + this.log.debug("[" + this.robot.name + "] > " + message); + break; + case DebugType.STATUS: + this.log.debug("[" + this.robot.name + "] " + message); + break; + case DebugType.INFO: + this.log.debug("[" + this.robot.name + "] " + message); + break; + } + } +} + +enum CleanType +{ + ALL, + SPOT +} + +enum DebugType +{ + ACTION, + STATUS, + INFO +} + +enum RobotService +{ + CLEAN_HOUSE = "Clean house", + CLEAN_SPOT = "Clean spot", + GO_TO_DOCK = "Go to dock", + DOCKED = "Docked sensor", + BIN_FULL = "Bin full sensor", + FIND_ME = "Find me", + SCHEDULE = "Schedule", + ECO = "Eco", + NOGO_LINES = "Nogo lines", + EXTRA_CARE = "Extra care" +} + +const BACKGROUND_INTERVAL = 30; +const PREFIX = false; +const SERVICES = Object.values(RobotService); \ No newline at end of file diff --git a/src/accessories/room.ts b/src/accessories/room.ts new file mode 100644 index 0000000..99dff1f --- /dev/null +++ b/src/accessories/room.ts @@ -0,0 +1,37 @@ +// import {CharacteristicValue, Logger, PlatformAccessory, PlatformConfig, Service} from 'homebridge'; +// import {HomebridgeNeatoPlatform} from '../homebridgeNeatoPlatform'; +// +// const debug = require('debug')('my-app:my-module'); +// +// /** +// * Platform Accessory +// * An instance of this class is created for each accessory your platform registers +// * Each accessory may expose multiple services of different service types. +// */ +// export class Room +// { +// +// private robot: any; +// private log: Logger; +// private readonly refresh: any; +// +// +// /** +// * These are just used to create a working example +// * You should implement your own code to track the state of your accessory +// */ +// +// constructor( +// private readonly platform: HomebridgeNeatoPlatform, +// private readonly accessory: PlatformAccessory, +// private readonly isNew: Boolean, +// private readonly config: PlatformConfig) +// { +// +// } +// +// async setCleanRoom() +// { +// +// } +// } diff --git a/src/characteristics/spotHeight.ts b/src/characteristics/spotHeight.ts new file mode 100644 index 0000000..1546aed --- /dev/null +++ b/src/characteristics/spotHeight.ts @@ -0,0 +1,19 @@ +import type { Characteristic, WithUUID } from 'homebridge'; +import { Formats, Perms } from 'homebridge'; + +export default function spotHeight(CustomCharacteristic: typeof Characteristic): WithUUID Characteristic> { + return class SpotHeight extends CustomCharacteristic { + static readonly UUID = 'CA282DB2-62BF-4325-A1BE-F8BB5478781A'; + + constructor() { + super('Spot ↕', SpotHeight.UUID, { + format: Formats.INT, + unit: 'cm', + maxValue: 400, + minValue: 100, + minStep: 50, + perms: [Perms.PAIRED_READ, Perms.PAIRED_WRITE] + }); + } + }; +} \ No newline at end of file diff --git a/src/characteristics/spotRepeat.ts b/src/characteristics/spotRepeat.ts new file mode 100644 index 0000000..f4d8f09 --- /dev/null +++ b/src/characteristics/spotRepeat.ts @@ -0,0 +1,15 @@ +import type { Characteristic, WithUUID } from 'homebridge'; +import { Formats, Perms } from 'homebridge'; + +export default function spotRepeat(CustomCharacteristic: typeof Characteristic): WithUUID Characteristic> { + return class SpotRepeat extends CustomCharacteristic { + static readonly UUID = '1E79C603-63B8-4E6A-9CE1-D31D67981831'; + + constructor() { + super('Spot 2x', SpotRepeat.UUID, { + format: Formats.BOOL, + perms: [Perms.PAIRED_READ, Perms.PAIRED_WRITE] + }); + } + }; +} \ No newline at end of file diff --git a/src/characteristics/spotWidth.ts b/src/characteristics/spotWidth.ts new file mode 100644 index 0000000..8632445 --- /dev/null +++ b/src/characteristics/spotWidth.ts @@ -0,0 +1,19 @@ +import type { Characteristic, WithUUID } from 'homebridge'; +import { Formats, Perms } from 'homebridge'; + +export default function spotWidth(CustomCharacteristic: typeof Characteristic): WithUUID Characteristic> { + return class SpotWidth extends CustomCharacteristic { + static readonly UUID = 'A7889A9A-2F27-4293-BEF8-3FE805B36F4E'; + + constructor() { + super('Spot ↔', SpotWidth.UUID, { + format: Formats.INT, + unit: 'cm', + maxValue: 400, + minValue: 100, + minStep: 50, + perms: [Perms.PAIRED_READ, Perms.PAIRED_WRITE] + }); + } + }; +} \ No newline at end of file diff --git a/src/homebridgeKoboldPlatform.ts b/src/homebridgeKoboldPlatform.ts new file mode 100644 index 0000000..e86e647 --- /dev/null +++ b/src/homebridgeKoboldPlatform.ts @@ -0,0 +1,219 @@ +import {API, Characteristic, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service} from "homebridge"; +import KoboldApi from "node-kobold-control"; +import {PLATFORM_NAME, PLUGIN_NAME} from "./settings"; +import {KoboldVacuumRobotAccessory} from "./accessories/koboldVacuumRobot"; + +/** + * HomebridgePlatform + * This class is the main constructor for your plugin, this is where you should + * parse the user config and discover/register accessories with Homebridge. + */ +export class HomebridgeKoboldPlatform implements DynamicPlatformPlugin +{ + public readonly Service: typeof Service = this.api.hap.Service; + public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; + + // this is used to track restored cached accessories + public readonly cachedRobotAccessories: PlatformAccessory[] = []; + + constructor( + public readonly log: Logger, + public readonly config: PlatformConfig, + public readonly api: API) + { + this.api.on("didFinishLaunching", () => { + this.discoverRobots(); + }); + } + + /** + * This function is invoked when homebridge restores cached accessories from disk at startup. + * It should be used to setup event handlers for characteristics and update respective values. + */ + configureAccessory(accessory: PlatformAccessory) + { + // add the restored accessory to the accessories cache so we can track if it has already been registered + this.cachedRobotAccessories.push(accessory); + } + + + discoverRobots() + { + const client = new KoboldApi.Client(); + this.log.debug("blubb"); + + try + { + // Login + client.authorize((this.config)["email"], (this.config)["password"], false, (error) => { + if (error) + { + this.log.error("Cannot connect to neato server. No new robots will be found and existing robots will be unresponsive. Retrying in 5 minutes."); + this.log.error("Error: " + error); + + setTimeout(() => { + this.discoverRobots(); + }, 5 * 60 * 1000); + return; + } + + // Get all robots from account + client.getRobots((error, robots) => { + if (error) + { + this.log.error("Successful login but can't list the robots in your neato robots. Retrying in 5 minutes."); + this.log.error("Error: " + error); + + setTimeout(() => { + this.discoverRobots(); + }, 5 * 60 * 1000); + return; + } + + // Neato robots in account + if (robots.length === 0) + { + this.log.error("Neato account has no robots. Did you add your robot here: https://neatorobotics.com/my-neato/ ?"); + } + else + { + this.log.info("Neato account has " + robots.length + " robot" + (robots.length === 1 ? "" : "s")); + } + + // Neato robots in cache + this.log.debug("Plugin Cache has " + this.cachedRobotAccessories.length + " robot" + (this.cachedRobotAccessories.length === 1 ? "" : "s")); + for (let cachedRobot of this.cachedRobotAccessories) + { + let accountRobot = robots.find(robot => this.api.hap.uuid.generate(robot._serial) === cachedRobot.UUID); + if (accountRobot) + { + this.log.debug("[" + cachedRobot.displayName + "] Cached robot found in Neato account."); + } + else + { + this.log.error("[" + cachedRobot.displayName + "] Cached robot not found in Neato account. Robot will now be removed from homebridge."); + this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [cachedRobot]); + } + } + + // Add / Update homebridge accessories with robot information from neato. This must be done for new and existing robots to reflect changes in the name, firmware, pluginconfig etc. + for (let robot of robots) + { + // Check if robot already exists as an accessory + const uuid = this.api.hap.uuid.generate(robot._serial); + const cachedRobot = this.cachedRobotAccessories.find(accessory => accessory.UUID === uuid); + + if (cachedRobot) + { + this.log.debug("[" + robot.name + "] Connecting to cached robot and updating information."); + } + else + { + this.log.debug("[" + robot.name + "] Connecting to new robot and updating information."); + } + + robot.getState((error, state) => { + if (error) + { + this.log.error("[" + robot.name + "] Cannot connect to robot. Is the robot connected to the internet? Retrying in 5 minutes."); + this.log.error("Error: " + error); + setTimeout(() => { + this.discoverRobots(); + }, 5 * 60 * 1000); + } + else + { + try + { + robot.meta = state.meta; + + // Update existing robot accessor + if (cachedRobot) + { + // TODO update maps + + cachedRobot.context.robot = robot; + this.api.updatePlatformAccessories([cachedRobot]); + new KoboldVacuumRobotAccessory(this, cachedRobot, this.config); + this.log.info("[" + robot.name + "] Successfully loaded robot from cache"); + } + // Create new robot accessory + else + { + // TODO get maps + + const newRobot = new this.api.platformAccessory(robot.name, uuid); + newRobot.context.robot = robot; + new KoboldVacuumRobotAccessory(this, newRobot, this.config); + this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [newRobot]); + this.log.info("[" + robot.name + "] Successfully created as new robot"); + } + } + catch (error) + { + this.log.error("[" + robot.name + "] Creating accessory failed. Error: " + error); + throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + } + } + + // // Get all maps for each robot + // robot.getPersistentMaps((error, maps) => { + // if (error) + // { + // this.log.error("Error updating persistent maps: " + error + ": " + maps); + // callback(); + // } + // // Robot has no maps + // else if (maps.length === 0) + // { + // robot.maps = []; + // this.robotAccessories.push({device: robot, meta: state.meta, availableServices: state.availableServices}); + // loadedRobots++; + // if (loadedRobots === robots.length) + // { + // callback(); + // } + // } + // // Robot has maps + // else + // { + // robot.maps = maps; + // let loadedMaps = 0; + // robot.maps.forEach((map) => { + // // Save zones in each map + // robot.getMapBoundaries(map.id, (error, result) => { + // if (error) + // { + // this.log.error("Error getting boundaries: " + error + ": " + result) + // } + // else + // { + // map.boundaries = result.boundaries; + // } + // loadedMaps++; + // + // // Robot is completely requested if zones for all maps are loaded + // if (loadedMaps === robot.maps.length) + // { + // this.robotAccessories.push({device: robot, meta: state.meta, availableServices: state.availableServices}); + // loadedRobots++; + // if (loadedRobots === robots.length) + // { + // callback(); + // } + // } + // }) + // }); + // } + // }); + }); + } + }); + }); + } + catch (error) + { + this.log.error("Can't log on to neato cloud. Please check your internet connection and your credentials. Try again later if the neato servers have issues. Error: " + error); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9564ba7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import {API} from "homebridge"; + +import {PLATFORM_NAME} from "./settings"; +import {HomebridgeKoboldPlatform} from "./homebridgeKoboldPlatform"; + +/** + * This method registers the platform with Homebridge + */ +export = (api: API) => +{ + api.registerPlatform(PLATFORM_NAME, HomebridgeKoboldPlatform); +}; diff --git a/src/models/options.ts b/src/models/options.ts new file mode 100644 index 0000000..f5f3293 --- /dev/null +++ b/src/models/options.ts @@ -0,0 +1,15 @@ +export class Options +{ + public eco: boolean; + public extraCare: boolean; + public noGoLines: boolean; + public spot: any; + + constructor() + { + this.eco = false; + this.extraCare = false; + this.noGoLines = false; + this.spot = {}; + } +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..28cda41 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,9 @@ +/** + * This is the name of the platform that users will use to register the plugin in the Homebridge config.json + */ +export const PLATFORM_NAME = "KoboldVacuumRobot"; + +/** + * This must match the name of your plugin as defined the package.json + */ +export const PLUGIN_NAME = "homebridge-kobold"; \ No newline at end of file