diff --git a/plugins/ecobee/ .npmignore b/plugins/ecobee/ .npmignore new file mode 100644 index 000000000..295d8caf5 --- /dev/null +++ b/plugins/ecobee/ .npmignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/*.map \ No newline at end of file diff --git a/plugins/ecobee/.gitignore b/plugins/ecobee/.gitignore new file mode 100644 index 000000000..9cdb546bf --- /dev/null +++ b/plugins/ecobee/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/ecobee/.vscode/launch.json b/plugins/ecobee/.vscode/launch.json new file mode 100644 index 000000000..0669f79b4 --- /dev/null +++ b/plugins/ecobee/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Scrypted Debugger", + "address": "${config:scrypted.debugHost}", + "port": 10081, + "request": "attach", + "skipFiles": [ + "/**" + ], + "preLaunchTask": "scrypted: deploy+debug", + "sourceMaps": true, + "localRoot": "${workspaceFolder}/out", + "remoteRoot": "/plugin/", + "type": "pwa-node" + } + ] +} \ No newline at end of file diff --git a/plugins/ecobee/.vscode/settings.json b/plugins/ecobee/.vscode/settings.json new file mode 100644 index 000000000..77ccdbd6d --- /dev/null +++ b/plugins/ecobee/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "127.0.0.1", +} \ No newline at end of file diff --git a/plugins/ecobee/.vscode/tasks.json b/plugins/ecobee/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/ecobee/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "scrypted: deploy+debug", + "type": "shell", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}", + }, + ] +} diff --git a/plugins/ecobee/README.md b/plugins/ecobee/README.md new file mode 100644 index 000000000..905dfc64d --- /dev/null +++ b/plugins/ecobee/README.md @@ -0,0 +1,15 @@ +# An Ecobee thermostat plugin + +## npm commands + * npm run scrypted-webpack + * npm run scrypted-deploy + * npm run scrypted-debug + +## scrypted distribution via npm + 1. Ensure package.json is set up properly for publishing on npm. + 2. npm publish + +## Visual Studio Code configuration + +* If using a remote server, edit [.vscode/settings.json](blob/master/.vscode/settings.json) to specify the IP Address of the Scrypted server. +* Launch Scrypted Debugger from the launch menu. diff --git a/plugins/ecobee/package-lock.json b/plugins/ecobee/package-lock.json new file mode 100644 index 000000000..bf2a24a86 --- /dev/null +++ b/plugins/ecobee/package-lock.json @@ -0,0 +1,263 @@ +{ + "name": "@scrypted/ecobee", + "version": "0.0.2", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@scrypted/ecobee", + "version": "0.0.2", + "license": "Apache", + "dependencies": { + "axios": "^0.18.0", + "terser": "^3.14.1" + }, + "devDependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.7.1" + } + }, + "../../sdk": { + "version": "0.0.93", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", + "@babel/plugin-proposal-numeric-separator": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/plugin-transform-modules-commonjs": "^7.15.4", + "@babel/plugin-transform-typescript": "^7.15.8", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.11.1", + "adm-zip": "^0.4.13", + "axios": "^0.21.4", + "babel-loader": "^8.2.3", + "babel-plugin-const-enum": "^1.1.0", + "esbuild": "^0.13.8", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "ts-loader": "^9.2.6", + "typescript-json-schema": "^0.50.1", + "webpack": "^5.59.0" + }, + "bin": { + "scrypted-debug": "bin/scrypted-debug.js", + "scrypted-deploy": "bin/scrypted-deploy.js", + "scrypted-deploy-debug": "bin/scrypted-deploy-debug.js", + "scrypted-package-json": "bin/scrypted-package-json.js", + "scrypted-readme": "bin/scrypted-readme.js", + "scrypted-webpack": "bin/scrypted-webpack.js" + } + }, + "node_modules/@scrypted/sdk": { + "resolved": "../../sdk", + "link": true + }, + "node_modules/@types/node": { + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==", + "dev": true + }, + "node_modules/axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", + "dependencies": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dependencies": { + "debug": "=3.1.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz", + "integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-3.17.0.tgz", + "integrity": "sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ==", + "dependencies": { + "commander": "^2.19.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.10" + }, + "bin": { + "terser": "bin/uglifyjs" + }, + "engines": { + "node": ">=6.0.0" + } + } + }, + "dependencies": { + "@scrypted/sdk": { + "version": "file:../../sdk", + "requires": { + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", + "@babel/plugin-proposal-numeric-separator": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/plugin-transform-modules-commonjs": "^7.15.4", + "@babel/plugin-transform-typescript": "^7.15.8", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.11.1", + "adm-zip": "^0.4.13", + "axios": "^0.21.4", + "babel-loader": "^8.2.3", + "babel-plugin-const-enum": "^1.1.0", + "esbuild": "^0.13.8", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "ts-loader": "^9.2.6", + "typescript-json-schema": "^0.50.1", + "webpack": "^5.59.0" + } + }, + "@types/node": { + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==", + "dev": true + }, + "axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "requires": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + }, + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz", + "integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "terser": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-3.17.0.tgz", + "integrity": "sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ==", + "requires": { + "commander": "^2.19.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.10" + } + } + } +} diff --git a/plugins/ecobee/package.json b/plugins/ecobee/package.json new file mode 100644 index 000000000..317d05261 --- /dev/null +++ b/plugins/ecobee/package.json @@ -0,0 +1,42 @@ +{ + "name": "@scrypted/ecobee", + "version": "0.0.6", + "description": "Ecobee API plugin", + "author": "Scrypted", + "license": "Apache", + "scripts": { + "prepublishOnly": "NODE_ENV=production scrypted-webpack", + "prescrypted-vscode-launch": "scrypted-webpack", + "scrypted-vscode-launch": "scrypted-deploy-debug", + "scrypted-deploy-debug": "scrypted-deploy-debug", + "scrypted-debug": "scrypted-debug", + "scrypted-deploy": "scrypted-deploy", + "scrypted-readme": "scrypted-readme", + "scrypted-package-json": "scrypted-package-json", + "scrypted-webpack": "scrypted-webpack" + }, + "keywords": [ + "ecobee", + "bryant", + "scrypted", + "plugin" + ], + "scrypted": { + "name": "Ecobee Controller", + "singleInstance": true, + "type": "DeviceProvider", + "interfaces": [ + "DeviceProvider", + "Settings" + ] + }, + "devDependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.7.1" + }, + "dependencies": { + "axios": "^0.18.0", + "terser": "^3.14.1" + } + } + \ No newline at end of file diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts new file mode 100644 index 000000000..3420e2650 --- /dev/null +++ b/plugins/ecobee/src/main.ts @@ -0,0 +1,638 @@ +import axios, { AxiosRequestConfig } from 'axios' +import sdk, { Device, DeviceInformation, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit, HumidityCommand, HumidityMode, HumiditySetting } from '@scrypted/sdk'; +const { deviceManager, log } = sdk; + +const API_RETRY = 2; + +// Convert Fahrenheit to Celsius, round to 2 decimal places +function convertFtoC(f: number) { + let c = (5/9) * (f - 32) + return Math.round(c*100)/100 +} + +// Convert Celsius to Fahrenheit, round to 1 decimal place +function convertCtoF(c: number) { + let f = (c * 1.8) + 32 + return Math.round(f*10)/10 +} + +function ecobeeToThermostatMode(mode: string) { + // Values: auto, auxHeatOnly, cool, heat, off + switch(mode) { + case "cool": + return ThermostatMode.Cool; + case "heat": + return ThermostatMode.Heat; + case "auto": + return ThermostatMode.Auto; + case "off": + return ThermostatMode.Off; + } +} + +function thermostatModeToEcobee(mode: ThermostatMode) { + // Values: auto, auxHeatOnly, cool, heat, off + switch(mode) { + case ThermostatMode.Cool: + return "cool"; + case ThermostatMode.Heat: + return "heat"; + case ThermostatMode.Auto: + return "auto"; + case ThermostatMode.Off: + return "off"; + } +} + +function humModeFromEcobee(mode: string): HumidityMode { + // Values: auto, manual, off + switch(mode) { + case 'auto': + return HumidityMode.Auto; + case "manual": + return HumidityMode.Humidify; + } + + return HumidityMode.Off +} + +class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Refresh, OnOff, HumiditySetting, Settings { + device: any; + revisionList: string[]; + provider: EcobeeController; + on: boolean; + + constructor(nativeId: string, provider: EcobeeController, info: DeviceInformation) { + super(nativeId); + this.provider = provider; + this.revisionList = null; + this.info = info; + + this.temperatureUnit = TemperatureUnit.F + const modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; + this.thermostatAvailableModes = modes; + + let humModes: HumidityMode[] = [HumidityMode.Auto, HumidityMode.Humidify, HumidityMode.Off]; + this.humiditySetting = { + mode: HumidityMode.Off, + availableModes: humModes, + } + + setImmediate(() => this.refresh("constructor", false)); + } + + async getSettings(): Promise { + return [ + { + title: 'Additional Devices', + key: 'additional_devices', + value: this.storage.getItem("additional_devices"), + choices: ['Fan', 'Humidifier'], + description: 'Display additional devices for components', + multiple: true, + } + ] + } + + async putSetting(key: string, value: string): Promise { + this.storage.setItem(key, value.toString()); + } + + /* + * Get the recommended refresh/poll frequency in seconds for this device. + */ + async getRefreshFrequency(): Promise { + return 15; + } + + /* refresh(): Request from Scrypted to refresh data from device + * Poll from API '/thermostatSummary' endpoint for timestamp of last changes and compare to last check + * Updates equipmentStatus on each call + */ + async refresh(refreshInterface: string, userInitiated: boolean): Promise { + this.console.log(`${refreshInterface} requested refresh\n ${new Date()}`) + + const json = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + includeEquipmentStatus: true, + } + } + const data = await this.provider.req('get', 'thermostatSummary', json) + + // Update equipmentStatus, trigger reload if changes detected + this._updateEquipmentStatus(data.statusList[0].split(":")[1]); + if (this._updateRevisionList(data.revisionList[0])) + await this.reload() + } + + /* + * Set characteristics based on equipmentStatus from API + * + * Possible eqipmentStatus values: + * heatPump, heatPump[2-3], compCool[1-2], auxHeat[1-3], + * fan, humidifier, dehumidifier, ventilator, economizer, + * compHotWater, auxHotWater + */ + _updateEquipmentStatus(equipmentStatus: string): void { + equipmentStatus = equipmentStatus.toLowerCase() + this.console.log(` Current status: ${equipmentStatus}`); + if (equipmentStatus.includes("heat")) + // values: heatPump, heatPump[2-3], auxHeat[1-3] + this.thermostatActiveMode = ThermostatMode.Heat; + else if (equipmentStatus.includes("cool")) + // values: compCool[1-2] + this.thermostatActiveMode = ThermostatMode.Cool; + else + this.thermostatActiveMode = ThermostatMode.Off; + + // fan status + if (equipmentStatus.includes('fan')) { + this.on = true; + } else { + this.on = false; + } + + // humidifier status + let activeMode = HumidityMode.Off + if (equipmentStatus.includes('humidifier')) { + activeMode = HumidityMode.Humidify + } + this.humiditySetting = Object.assign(this.humiditySetting, { activeMode }); + } + + /* revisionListChanged(): Compare a new revision list to the stored list, return true if changed + * + */ + _updateRevisionList(listStr: string): boolean { + const listItems = ["tId", "tName", "connected", "thermostat", "alerts", "runtime", "interval"]; + const oldList = this.revisionList; + this.revisionList = listStr.split(':'); + + if (!oldList) + return true; + + // Compare each element, skip first 3 + for (let i = 3; i < listItems.length; i++) { + if (this.revisionList[i] !== oldList[i]) { + this.console.log(` Changes detected: ${listItems[i]}`) + return true; + } + } + + this.console.log(" Changes detected: none"); + return false; + } + + /* reload(): Reload all thermostat data from API '/thermostat' endpoint + * + */ + async reload(): Promise { + const json = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + includeSettings: true, + includeRuntime: true, + includeEquipmentStatus: true, + } + } + const data = (await this.provider.req('get', 'thermostat', json)).thermostatList[0]; + + // Set runtime values + this.temperature = convertFtoC(Number(data.runtime.actualTemperature)/10) + this.humidity = Number(data.runtime.actualHumidity); + + // Set current equipment status values + this._updateEquipmentStatus(data.equipmentStatus); + + // update based on mode + this.thermostatMode = ecobeeToThermostatMode(data.settings.hvacMode); + + this.thermostatSetpointHigh = convertFtoC(Number(data.runtime.desiredCool)/10) + this.thermostatSetpointLow = convertFtoC(Number(data.runtime.desiredHeat)/10) + switch(data.settings.hvacMode) { + case 'cool': + this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredCool)/10) + break; + case 'heat': + this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredHeat)/10) + break; + } + + // update humidifier based on mode + this.humiditySetting = Object.assign(this.humiditySetting, { + mode: humModeFromEcobee(data.settings.humidifierMode), + humidifierSetpoint: Number(data.settings.humidity), + }); + } + + async setHumidity(humidity: HumidityCommand): Promise { + this.console.log(`setHumidity ${humidity.mode} ${humidity.humidifierSetpoint}: not yet supported`); + } + + async setThermostatMode(mode: ThermostatMode): Promise { + this.console.log(`setThermostatMode ${mode}`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + thermostat: { + settings:{ + hvacMode: thermostatModeToEcobee(mode) + } + } + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data); + if (resp.status.code == 0) { + this.console.log("setThermostatMode success") + await this.reload(); + return; + } + + this.console.log(`setThermostatMode failed: ${resp}`) + } + + async setThermostatSetpoint(degrees: number): Promise { + const degF = convertCtoF(degrees); + this.console.log(`setThermostatSetpoint ${degrees}C/${degF}F`) + + if (this.thermostatMode === ThermostatMode.Auto) { + this.console.log(`setThermostatSetpoint not running in auto mode`) + return; + } + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params:{ + holdType: "nextTransition", + heatHoldTemp: degF*10, + coolHoldTemp: degF*10, + } + } + ] + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data) + if (resp.status.code == 0) { + this.console.log("setThermostatSetpoint success") + await this.reload(); + return; + } + + this.console.log(`setThermostatSetpoint failed: ${resp}`) + } + + async setThermostatSetpointHigh(high: number): Promise { + const degFLow = convertCtoF(this.thermostatSetpointLow); + const degFHigh = convertCtoF(high); + this.console.log(`setThermostatSetpointHigh ${high}C/${degFHigh}F`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params:{ + holdType: "nextTransition", + heatHoldTemp: degFLow*10, + coolHoldTemp: degFHigh*10, + } + } + ] + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data) + if (resp.status.code == 0) { + this.console.log("setThermostatSetpointHigh success") + await this.reload(); + return; + } + + this.console.log(`setThermostatSetpointHigh failed: ${resp}`) + } + + async setThermostatSetpointLow(low: number): Promise { + const degFLow = convertCtoF(low); + const degFHigh = convertCtoF(this.thermostatSetpointHigh); + this.console.log(`setThermostatSetpointLow ${low}C/${degFLow}F`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params:{ + holdType: "nextTransition", + heatHoldTemp: degFLow*10, + coolHoldTemp: degFHigh*10, + } + } + ] + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data) + if (resp.status.code == 0) { + this.console.log("setThermostatSetpointLow success") + await this.reload(); + return; + } + + this.console.log(`setThermostatSetpointLow failed: ${resp}`) + } + + async turnOff(): Promise { + this.console.log(`fanOff: setting fan to auto`) + + const data = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type: "setHold", + params: { + coolHoldTemp: 900, + heatHoldTemp: 550, + holdType: "nextTransition", + fan: "auto", + isTemperatureAbsolute: "false", + isTemperatureRelative: "false", + } + } + ] + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data); + if (resp.status.code == 0) { + this.console.log("fanOff success") + await this.reload(); + return; + } + + this.console.log(`fanOff failed: ${resp}`) + } + + async turnOn(): Promise { + this.console.log(`fanOn: setting fan to on`) + + const data = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params: { + coolHoldTemp: 900, + heatHoldTemp: 550, + holdType: "nextTransition", + fan: "on", + isTemperatureAbsolute: "false", + isTemperatureRelative: "false", + } + } + ] + } + + const resp = await this.provider.req('post', 'thermostat', undefined, data); + if (resp.status.code == 0) { + this.console.log("fanOn success") + await this.reload(); + return; + } + + this.console.log(`fanOn failed: ${resp}`) + } +} + +class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Settings { + devices = new Map(); + access_token: string; + + constructor() { + super() + this.log.clearAlerts(); + if (!this.storage.getItem("api_base")) + this.storage.setItem("api_base", "api.ecobee.com"); + + this.initialize(); + } + + async initialize(): Promise { + // If no clientId, request clientId to start authentication process + if (!this.storage.getItem("client_id")) { + this.log.a("You must specify a client ID.") + this.console.log("Enter a client ID for this app from the Ecobee developer portal. Then, collect the PIN and enter in Ecobee 'My Apps'. Restart this app to complete.") + return; + } + + if (!this.storage.getItem("refresh_token")) + // If no refresh_token, try to get token + await this.getToken(); + else if (!this.access_token) + await this.refreshToken(); + + this.discoverDevices(); + } + + async getSettings(): Promise { + return [ + { + group: "API", + title: "API Base URL", + key: "api_base", + description: "Customize the API base URL", + value: this.storage.getItem("api_base"), + }, + { + group: "API", + title: "API Client ID", + key: "client_id", + description: "Your Client ID from the Ecboee developer portal", + value: this.storage.getItem("client_id"), + } + ] + } + + async putSetting(key: string, value: string): Promise { + this.storage.setItem(key, value.toString()); + + // Try to get a code when a client ID is saved + if (key === "client_id") { + await this.getCode(); + } + } + + // Get a code from Ecobee API for user verification + async getCode() { + // GET https://api.ecobee.com/authorize?response_type=ecobeePin&client_id=APP_KEY&scope=SCOPE + const authUrl = `https://${this.storage.getItem("api_base")}/authorize` + const authParams = { + response_type:'ecobeePin', + scope: "smartWrite", + client_id: this.storage.getItem("client_id"), + } + let authData = (await axios.get(authUrl, { + params: authParams, + })).data + + this.log.clearAlerts(); + this.log.a(`Got code ${authData.ecobeePin}. Enter this in 'My Apps' Ecobee portal. Then restart this app.`) + this.storage.setItem("ecobee_code", authData.code); + } + + // Trade the validated code for an access token + async getToken() { + // POST https://api.ecobee.com/token?grant_type=ecobeePin&code=AUTHORIZATION_TOKEN&client_id=APP_KEY&ecobee_type=jwt + const tokenUrl = `https://${this.storage.getItem("api_base")}/token` + const tokenParams = { + grant_type:'ecobeePin', + code: this.storage.getItem("ecobee_code"), + client_id: this.storage.getItem("client_id"), + ecobee_type: "jwt", + }; + let tokenData = (await axios.post(tokenUrl, null, { + params: tokenParams + })).data; + this.access_token = tokenData.access_token; + this.storage.setItem("refresh_token", tokenData.refresh_token); + console.log(`Stored access/refresh token`) + } + + // Refresh the tokens + async refreshToken() { + // POST https://api.ecobee.com/token?grant_type=refresh_token&refresh_token=REFRESH_TOKEN&client_id=APP_KEY&ecobee_type=jwt + const tokenUrl = `https://${this.storage.getItem("api_base")}/token` + const tokenParams = { + grant_type:'refresh_token', + refresh_token: this.storage.getItem("refresh_token"), + client_id: this.storage.getItem("client_id"), + ecobee_type: "jwt", + }; + let tokenData = (await axios.post(tokenUrl, null, { + params: tokenParams + })).data; + this.access_token = tokenData.access_token; + this.storage.setItem("refresh_token", tokenData.refresh_token); + console.log(`Refreshed access/refresh token`) + } + + // Generic API request + async req( + method: string, + endpoint: string, + json?: any, + data?: any, + attempt?: number, + ): Promise { + if (attempt > API_RETRY) { + throw new Error(` request to ${method}:${endpoint} failed after ${attempt} retries`); + } + + // Configure API request + const config: AxiosRequestConfig = { + method, + baseURL: `https://${this.storage.getItem("api_base")}/api/1/`, + url: endpoint, + headers: { + Authorization: `Bearer ${this.access_token}`, + }, + data, + timeout: 10000, + } + if (json) + config.params = { json }; + + // Make API request, recursively retry after token refresh + try { + return (await axios.request(config)).data; + } catch (e) { + this.console.log(`req failed ${e}`) + // refresh token and retry request + await this.refreshToken(); + return await this.req(method, endpoint, json, data, attempt++); + } + } + + async discoverDevices(): Promise { + // Get a list of all accessible devices + const json = { + selection: { + selectionType: "registered", + selectionMatch: "", + includeSettings: true, + } + } + const apiDevices = (await this.req('get', 'thermostat', json)).thermostatList; + this.console.log(`Discovered ${apiDevices.length} devices.`); + + // Create a list of devices found from the API + const devices: Device[] = []; + for (let apiDevice of apiDevices) { + this.console.log(` Discovered ${apiDevice.brand} ${apiDevice.modelNumber} ${apiDevice.name} (${apiDevice.identifier})`); + + const interfaces: ScryptedInterface[] = [ + ScryptedInterface.Thermometer, + ScryptedInterface.TemperatureSetting, + ScryptedInterface.Refresh, + ScryptedInterface.HumiditySensor, + ScryptedInterface.OnOff, + ScryptedInterface.Settings, + ] + if (apiDevice.settings.hasHumidifier) + interfaces.push(ScryptedInterface.HumiditySetting); + + const device: Device = { + nativeId: apiDevice.identifier, + name: `${apiDevice.modelNumber} thermostat`, + type: ScryptedDeviceType.Thermostat, + info: { + model: apiDevice.brand, + manufacturer: apiDevice.modelNumber, + serialNumber: apiDevice.identifier, + }, + interfaces, + } + devices.push(device); + } + + // Sync full device list + await deviceManager.onDevicesChanged({ + devices, + }); + + for (let device of devices) { + let providerDevice = this.devices.get(device.nativeId); + if (!providerDevice) { + providerDevice = new EcobeeThermostat(device.nativeId, this, device.info) + this.devices.set(device.nativeId, providerDevice) + } + } + } + + getDevice(nativeId: string) { + return this.devices.get(nativeId); + } + +} + +export default new EcobeeController();