From 1bf31a2e74bdfadb72edc5a7d41fbe438694e7ac Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Thu, 28 Oct 2021 20:02:43 -0400 Subject: [PATCH 01/18] ecobee: initial plugin --- plugins/ecobee/ .npmignore | 4 + plugins/ecobee/.gitignore | 4 + plugins/ecobee/.vscode/launch.json | 22 ++ plugins/ecobee/.vscode/settings.json | 4 + plugins/ecobee/.vscode/tasks.json | 20 ++ plugins/ecobee/README.md | 15 + plugins/ecobee/package-lock.json | 263 +++++++++++++++++ plugins/ecobee/package.json | 42 +++ plugins/ecobee/src/main.ts | 422 +++++++++++++++++++++++++++ 9 files changed, 796 insertions(+) create mode 100644 plugins/ecobee/ .npmignore create mode 100644 plugins/ecobee/.gitignore create mode 100644 plugins/ecobee/.vscode/launch.json create mode 100644 plugins/ecobee/.vscode/settings.json create mode 100644 plugins/ecobee/.vscode/tasks.json create mode 100644 plugins/ecobee/README.md create mode 100644 plugins/ecobee/package-lock.json create mode 100644 plugins/ecobee/package.json create mode 100644 plugins/ecobee/src/main.ts 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..6345f087c --- /dev/null +++ b/plugins/ecobee/package.json @@ -0,0 +1,42 @@ +{ + "name": "@scrypted/ecobee", + "version": "0.0.2", + "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..bcf632be4 --- /dev/null +++ b/plugins/ecobee/src/main.ts @@ -0,0 +1,422 @@ +import axios, { AxiosRequestConfig } from 'axios' +import sdk, { Device, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; +const { deviceManager, log } = sdk; + +// Convert Fahrenheit to Celsius +function convertFtoC(f: number) { + return (5/9) * (f - 32) +} + +// Convert Celsius to Fahrenheit +function convertCtoF(c: number) { + return (c * 1.8) + 32 +} + +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"; + } +} + +class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Refresh { + device: any; + revisionList: string[]; + provider: EcobeeController; + + constructor(nativeId: string, provider: EcobeeController) { + super(nativeId); + this.provider = provider; + this.revisionList = null; + + setImmediate(() => this.refresh("constructor", false)); + } + + /* initialconfig(): set initial device characteristics + * + */ + initialconfig(): void { + this.temperatureUnit = TemperatureUnit.F + var modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; + this.thermostatAvailableModes = modes; + } + + /* + * Get the recommended refresh/poll frequency in seconds for this device. + */ + async getRefreshFrequency(): Promise { + return 30; + } + + /* refresh(): Request from Scrypted to refresh data from device + * + */ + async refresh(refreshInterface: string, userInitiated: boolean): Promise { + this._refresh(); + } + + + /* _refresh(): Poll from API '/thermostatSummary' endpoint for timestamp of last changes and compare to last check + * Updates equipmentStatus on each call + * + */ + async _refresh(): Promise { + const data = await this.provider.req('get', 'thermostatSummary', null, { + json: `\{"selection":\{"selectionType":"registered","selectionMatch":"${this.nativeId}","includeSettings": "true", "includeRuntime": "true", "includeEquipmentStatus": "true"\}\}` + }); + + // 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 + */ + _updateEquipmentStatus(equipmentStatus: string): void { + equipmentStatus = equipmentStatus.toLowerCase() + this.console.log(`_updateEquipmentStatus ${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; + } + + /* 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(`Change detected: ${listItems[i]}`) + return true; + } + } + + this.console.log("No changes detected"); + return false; + } + + /* reload(): Reload all thermostat data from API '/thermostat' endpoint + * + */ + async reload(): Promise { + var data = (await this.provider.req('get', 'thermostat', {}, { + json: `\{"selection":\{"selectionType":"registered","selectionMatch":"${this.nativeId}","includeSettings": "true", "includeRuntime": "true", "includeEquipmentStatus": "true"\}\}` + })).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); + switch(data.settings.hvacMode) { + case 'auto': + // TODO: figure out setpoint range + break; + case 'cool': + this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredCool)/10) + break; + case 'heat': + this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredHeat)/10) + break; + } + } + + async setThermostatMode(mode: ThermostatMode): Promise { + this.console.log(`setThermostatMode ${mode}`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + thermostat: { + settings:{ + hvacMode: thermostatModeToEcobee(mode) + } + } + } + + var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + 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 = Math.round(convertCtoF(degrees)*10); + this.console.log(`setThermostatSetpoint ${degrees}C/${degF}F`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params:{ + holdType: "nextTransition", + heatHoldTemp: degF, + coolHoldTemp: degF, + } + } + ] + } + + var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + 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 { + this.console.log(`setThermostatSetpointHigh ${high}`) + return; + } + + async setThermostatSetpointLow(low: number): Promise { + this.console.log(`setThermostatSetpointLow ${low}`) + return; + } + +} + +class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Settings { + devices = new Map(); + clientId: string; + apiBaseUrl: string; + ecobeeCode: string; + access_token: string; + refresh_token: string; + + constructor() { + super() + this.clientId = this.storage.getItem("client_id"); + this.apiBaseUrl = this.storage.getItem("api_base") || "api.ecobee.com"; + this.access_token = this.storage.getItem("access_token"); + this.refresh_token = this.storage.getItem("refresh_token"); + + this.discoverDevices() + } + + async getSettings(): Promise { + return [ + { + group: "API", + title: "API Base URL", + key: "api_base", + description: "Customize the API base URL", + value: this.apiBaseUrl, + }, + { + group: "API", + title: "API Client ID", + key: "client_id", + description: "Your Client ID from the Ecboee developer portal", + value: this.clientId, + }, + { + group: "API Detail", + title: "Access Token", + key: "access_token", + readonly: true, + value: this.access_token || "You must complete the authentication process", + }, + { + group: "API Detail", + title: "Refresh Token", + key: "refresh_token", + readonly: true, + value: this.refresh_token || "You must complete the authentication process", + } + ] + } + + async putSetting(key: string, value: string): Promise { + this.storage.setItem(key, value.toString()); + } + + // 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.apiBaseUrl}/authorize` + const authParams = { + response_type:'ecobeePin', + scope: "smartWrite", + client_id: this.clientId, + } + let authData = (await axios.get(authUrl, { + params: authParams, + })).data + + this.console.log(`Got code ${authData.ecobeePin}. Enter this in 'My Apps'.`) + this.ecobeeCode = 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.apiBaseUrl}/token` + const tokenParams = { + grant_type:'ecobeePin', + code: this.ecobeeCode, + client_id: this.clientId, + ecobee_type: "jwt", + }; + let tokenData = (await axios.post(tokenUrl, null, { + params: tokenParams + })).data; + this.access_token = tokenData.access_token; + this.refresh_token = tokenData.refresh_token; + this.storage.setItem("access_token", this.access_token); + this.storage.setItem("refresh_token", this.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.apiBaseUrl}/token` + const tokenParams = { + grant_type:'refresh_token', + refresh_token: this.refresh_token, + client_id: this.clientId, + ecobee_type: "jwt", + }; + let tokenData = (await axios.post(tokenUrl, null, { + params: tokenParams + })).data; + this.access_token = tokenData.access_token; + this.refresh_token = tokenData.refresh_token; + this.storage.setItem("access_token", this.access_token); + this.storage.setItem("refresh_token", this.refresh_token); + console.log(`Refreshed access/refresh token`) + } + + // Generic API request + async req(method, endpoint, data, params) { + const url = `https://${this.apiBaseUrl}/api/1/${endpoint}` + const options = { + headers: { + Authorization: `Bearer ${this.access_token}`, + }, + params: params + } + let resp + try { + if (method === "post") { + resp = await axios.post(url, data, options) + } else { + resp = await axios[method](url, options); + } + } catch (e) { + // really simple retry on failure + this.console.log("failed req(), refreshing token") + await this.refreshToken(); + resp = await axios[method](url, options); + } + return resp.data; + } + + async discoverDevices(): Promise { + // Check that required properties exist + this.log.clearAlerts(); + + if (!this.clientId) { + this.log.a("You must specify a client ID.") + return; + } + + if (!this.access_token) { + this.log.a("You must complete the authentication process.") + return; + } + + // Get a list of all accessible devices + const devices = (await this.req('get', 'thermostat', null, { + json: `\{"selection":\{"selectionType":"registered","selectionMatch":""\}\}` + })).thermostatList + this.console.log(`Discovered ${devices.length} devices.`) + + let deviceList = []; + for (let i = 0; i < devices.length; i++) { + deviceList.push({ + nativeId: devices[i].identifier, + name: `${devices[i].modelNumber} thermostat`, + type: ScryptedDeviceType.Thermostat, + interfaces: [ + ScryptedInterface.HumiditySensor, + ScryptedInterface.Thermometer, + ScryptedInterface.TemperatureSetting, + ScryptedInterface.Refresh + ] + }) + let device = this.devices.get(devices[i].identifier); + if (!device) { + device = new EcobeeThermostat(devices[i].identifier, this) + this.devices.set(devices[i].identifier, device) + device.initialconfig() + } + } + + // Sync full device list + await deviceManager.onDevicesChanged({ + devices: deviceList, + }); + } + + getDevice(nativeId: string) { + return this.devices.get(nativeId); + } + +} + +export default new EcobeeController(); \ No newline at end of file From a64421fcd073aee6df8ece2095fca04c1f305c3f Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 31 Oct 2021 01:01:27 -0400 Subject: [PATCH 02/18] ecobee: WIP fan off/on --- plugins/ecobee/src/main.ts | 56 +++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index bcf632be4..6f60e1456 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios' +import axios from 'axios' import sdk, { Device, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; const { deviceManager, log } = sdk; @@ -40,10 +40,11 @@ function thermostatModeToEcobee(mode: ThermostatMode) { } } -class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Refresh { +class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Refresh, OnOff { device: any; revisionList: string[]; provider: EcobeeController; + on: boolean; constructor(nativeId: string, provider: EcobeeController) { super(nativeId); @@ -73,10 +74,10 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The * */ async refresh(refreshInterface: string, userInitiated: boolean): Promise { + this.console.log(`refresh(${refreshInterface}, ${userInitiated}): ${new Date()}`) this._refresh(); } - /* _refresh(): Poll from API '/thermostatSummary' endpoint for timestamp of last changes and compare to last check * Updates equipmentStatus on each call * @@ -97,7 +98,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The */ _updateEquipmentStatus(equipmentStatus: string): void { equipmentStatus = equipmentStatus.toLowerCase() - this.console.log(`_updateEquipmentStatus ${equipmentStatus}`); + this.console.log(`Equipment status: ${equipmentStatus}`); if (equipmentStatus.includes("heat")) // values: heatPump, heatPump[2-3], auxHeat[1-3] this.thermostatActiveMode = ThermostatMode.Heat; @@ -106,6 +107,13 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The this.thermostatActiveMode = ThermostatMode.Cool; else this.thermostatActiveMode = ThermostatMode.Off; + + // fan status + if (equipmentStatus.includes('fan')) { + this.on = true; + } else { + this.on = false; + } } /* revisionListChanged(): Compare a new revision list to the stored list, return true if changed @@ -115,7 +123,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The const listItems = ["tId", "tName", "connected", "thermostat", "alerts", "runtime", "interval"]; const oldList = this.revisionList; this.revisionList = listStr.split(':'); - + if (!oldList) return true; @@ -138,7 +146,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The var data = (await this.provider.req('get', 'thermostat', {}, { json: `\{"selection":\{"selectionType":"registered","selectionMatch":"${this.nativeId}","includeSettings": "true", "includeRuntime": "true", "includeEquipmentStatus": "true"\}\}` })).thermostatList[0]; - + // Set runtime values this.temperature = convertFtoC(Number(data.runtime.actualTemperature)/10) this.humidity = Number(data.runtime.actualHumidity); @@ -227,6 +235,39 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The return; } + async turnOff(): Promise { + this.console.log(`fanOff`) + // TODO: would need to resume program + } + + async turnOn(): Promise { + this.console.log(`fanOn`) + + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"setHold", + params:{ + holdType: "nextTransition", + fan: "on", + } + } + ] + } + + var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + 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 { @@ -396,7 +437,8 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set ScryptedInterface.HumiditySensor, ScryptedInterface.Thermometer, ScryptedInterface.TemperatureSetting, - ScryptedInterface.Refresh + ScryptedInterface.Refresh, + ScryptedInterface.OnOff, ] }) let device = this.devices.get(devices[i].identifier); From 58d8a0827766c8d8b0c21a8f850a4671866c338d Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 31 Oct 2021 11:38:42 -0400 Subject: [PATCH 03/18] ecobee: add device info --- plugins/ecobee/src/main.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 6f60e1456..600b620e1 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -57,10 +57,18 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The /* initialconfig(): set initial device characteristics * */ - initialconfig(): void { + initialconfig(data): void { this.temperatureUnit = TemperatureUnit.F var modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; this.thermostatAvailableModes = modes; + + this.console.log(data); + // set device info + this.info = { + model: data.brand, + manufacturer: data.modelNumber, + serialNumber: data.identifier, + } } /* @@ -445,7 +453,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set if (!device) { device = new EcobeeThermostat(devices[i].identifier, this) this.devices.set(devices[i].identifier, device) - device.initialconfig() + device.initialconfig(devices[i]) } } From 08ece23c60a95627fa7a126c1f22461ee646f81e Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 31 Oct 2021 13:28:59 -0400 Subject: [PATCH 04/18] resume program, fan off --- plugins/ecobee/src/main.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 600b620e1..14a629c91 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -245,7 +245,31 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The async turnOff(): Promise { this.console.log(`fanOff`) - // TODO: would need to resume program + // resume program + // https://www.ecobee.com/home/developer/api/documentation/v1/functions/ResumeProgram.shtml + const data = { + selection: { + selectionType:"registered", + selectionMatch: this.nativeId, + }, + functions: [ + { + type:"resumeProgram", + params:{ + resumeAll: "false", + } + } + ] + } + + var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + if (resp.status.code == 0) { + this.console.log("fanOff success") + await this.reload(); + return; + } + + this.console.log(`fanOff failed: ${resp}`) } async turnOn(): Promise { From bb8b2680fb03a31534ab952d3159af562c4a61b0 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 31 Oct 2021 14:09:51 -0400 Subject: [PATCH 05/18] ecobee: round all F/C conversions --- plugins/ecobee/src/main.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 14a629c91..4d4fdd54c 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -2,14 +2,16 @@ import axios from 'axios' import sdk, { Device, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; const { deviceManager, log } = sdk; -// Convert Fahrenheit to Celsius +// Convert Fahrenheit to Celsius, round to 2 decimal places function convertFtoC(f: number) { - return (5/9) * (f - 32) + let c = (5/9) * (f - 32) + return Math.round(c*100)/100 } -// Convert Celsius to Fahrenheit +// Convert Celsius to Fahrenheit, round to 1 decimal place function convertCtoF(c: number) { - return (c * 1.8) + 32 + let f = (c * 1.8) + 32 + return Math.round(f*10)/10 } function ecobeeToThermostatMode(mode: string) { @@ -203,7 +205,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } async setThermostatSetpoint(degrees: number): Promise { - const degF = Math.round(convertCtoF(degrees)*10); + const degF = convertCtoF(degrees)*10; this.console.log(`setThermostatSetpoint ${degrees}C/${degF}F`) const data = { From b40d7a367270204f5b4476fbb75ed79ba040f7d3 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 7 Nov 2021 20:35:18 -0500 Subject: [PATCH 06/18] ecobee: fix fanOn/fanOff --- plugins/ecobee/package.json | 2 +- plugins/ecobee/src/main.ts | 77 ++++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/plugins/ecobee/package.json b/plugins/ecobee/package.json index 6345f087c..96ee74e90 100644 --- a/plugins/ecobee/package.json +++ b/plugins/ecobee/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/ecobee", - "version": "0.0.2", + "version": "0.0.3", "description": "Ecobee API plugin", "author": "Scrypted", "license": "Apache", diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 4d4fdd54c..9fc08aecf 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import sdk, { Device, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; +import sdk, { Device, DeviceInformation, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; const { deviceManager, log } = sdk; // Convert Fahrenheit to Celsius, round to 2 decimal places @@ -46,38 +46,28 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The device: any; revisionList: string[]; provider: EcobeeController; + information: DeviceInformation; on: boolean; + humOn: boolean; - constructor(nativeId: string, provider: EcobeeController) { + constructor(nativeId: string, provider: EcobeeController, info: DeviceInformation) { super(nativeId); this.provider = provider; this.revisionList = null; + this.info = info; - setImmediate(() => this.refresh("constructor", false)); - } - - /* initialconfig(): set initial device characteristics - * - */ - initialconfig(data): void { this.temperatureUnit = TemperatureUnit.F var modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; this.thermostatAvailableModes = modes; - this.console.log(data); - // set device info - this.info = { - model: data.brand, - manufacturer: data.modelNumber, - serialNumber: data.identifier, - } + setImmediate(() => this.refresh("constructor", false)); } /* * Get the recommended refresh/poll frequency in seconds for this device. */ async getRefreshFrequency(): Promise { - return 30; + return 15; } /* refresh(): Request from Scrypted to refresh data from device @@ -105,6 +95,11 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The /* * 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() @@ -124,6 +119,13 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } else { this.on = false; } + + // humidifier status + if (equipmentStatus.includes('humidifier')) { + this.humOn = true; + } else { + this.humOn = false; + } } /* revisionListChanged(): Compare a new revision list to the stored list, return true if changed @@ -168,7 +170,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The this.thermostatMode = ecobeeToThermostatMode(data.settings.hvacMode); switch(data.settings.hvacMode) { case 'auto': - // TODO: figure out setpoint range + // TODO: need scrypted support for setpoint range? break; case 'cool': this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredCool)/10) @@ -246,19 +248,23 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } async turnOff(): Promise { - this.console.log(`fanOff`) - // resume program - // https://www.ecobee.com/home/developer/api/documentation/v1/functions/ResumeProgram.shtml + this.console.log(`fanOff: setting fan to auto`) + const data = { selection: { - selectionType:"registered", + selectionType: "registered", selectionMatch: this.nativeId, }, functions: [ { - type:"resumeProgram", - params:{ - resumeAll: "false", + type: "setHold", + params: { + coolHoldTemp: 900, + heatHoldTemp: 550, + holdType: "nextTransition", + fan: "auto", + isTemperatureAbsolute: "false", + isTemperatureRelative: "false", } } ] @@ -275,19 +281,23 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } async turnOn(): Promise { - this.console.log(`fanOn`) + this.console.log(`fanOn: setting fan to on`) const data = { selection: { - selectionType:"registered", + selectionType: "registered", selectionMatch: this.nativeId, }, functions: [ { type:"setHold", - params:{ + params: { + coolHoldTemp: 900, + heatHoldTemp: 550, holdType: "nextTransition", fan: "on", + isTemperatureAbsolute: "false", + isTemperatureRelative: "false", } } ] @@ -463,10 +473,17 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set let deviceList = []; for (let i = 0; i < devices.length; i++) { + var info: DeviceInformation = { + model: devices[i].brand, + manufacturer: devices[i].modelNumber, + serialNumber: devices[i].identifier, + } + deviceList.push({ nativeId: devices[i].identifier, name: `${devices[i].modelNumber} thermostat`, type: ScryptedDeviceType.Thermostat, + info: info, interfaces: [ ScryptedInterface.HumiditySensor, ScryptedInterface.Thermometer, @@ -477,9 +494,9 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set }) let device = this.devices.get(devices[i].identifier); if (!device) { - device = new EcobeeThermostat(devices[i].identifier, this) + // set device info + device = new EcobeeThermostat(devices[i].identifier, this, info) this.devices.set(devices[i].identifier, device) - device.initialconfig(devices[i]) } } From 9845ba77d301e1d0b24af381b9aed7cc3e5b4b1c Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 7 Nov 2021 20:54:11 -0500 Subject: [PATCH 07/18] ecobee: update device discovery logic --- plugins/ecobee/package.json | 2 +- plugins/ecobee/src/main.ts | 44 ++++++++++++++++++------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/plugins/ecobee/package.json b/plugins/ecobee/package.json index 96ee74e90..d012ee01a 100644 --- a/plugins/ecobee/package.json +++ b/plugins/ecobee/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/ecobee", - "version": "0.0.3", + "version": "0.0.4", "description": "Ecobee API plugin", "author": "Scrypted", "license": "Apache", diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 9fc08aecf..ea757d783 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -466,43 +466,43 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } // Get a list of all accessible devices - const devices = (await this.req('get', 'thermostat', null, { + const apiDevices = (await this.req('get', 'thermostat', null, { json: `\{"selection":\{"selectionType":"registered","selectionMatch":""\}\}` })).thermostatList - this.console.log(`Discovered ${devices.length} devices.`) + this.console.log(`Discovered ${apiDevices.length} devices.`) - let deviceList = []; - for (let i = 0; i < devices.length; i++) { - var info: DeviceInformation = { - model: devices[i].brand, - manufacturer: devices[i].modelNumber, - serialNumber: devices[i].identifier, - } - - deviceList.push({ - nativeId: devices[i].identifier, - name: `${devices[i].modelNumber} thermostat`, + // Create a list of devices found from the API + const devices: Device[] = []; + for (let apiDevice of apiDevices) { + const device: Device = { + nativeId: apiDevice.identifier, + name: `${apiDevice.modelNumber} thermostat`, type: ScryptedDeviceType.Thermostat, - info: info, + info: { + model: apiDevice.brand, + manufacturer: apiDevice.modelNumber, + serialNumber: apiDevice.identifier, + }, interfaces: [ ScryptedInterface.HumiditySensor, ScryptedInterface.Thermometer, ScryptedInterface.TemperatureSetting, ScryptedInterface.Refresh, ScryptedInterface.OnOff, - ] - }) - let device = this.devices.get(devices[i].identifier); - if (!device) { - // set device info - device = new EcobeeThermostat(devices[i].identifier, this, info) - this.devices.set(devices[i].identifier, device) + ], + } + devices.push(device); + + let providerDevice = this.devices.get(device.nativeId); + if (!providerDevice) { + providerDevice = new EcobeeThermostat(device.nativeId, this, device.info) + this.devices.set(apiDevice.identifier, providerDevice) } } // Sync full device list await deviceManager.onDevicesChanged({ - devices: deviceList, + devices, }); } From f43647c9d335dfdc3f4dfb045917304fc737570f Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 7 Nov 2021 20:57:12 -0500 Subject: [PATCH 08/18] ecobee: remove unnescessary information parameter --- plugins/ecobee/src/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index ea757d783..ce3e90258 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -46,7 +46,6 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The device: any; revisionList: string[]; provider: EcobeeController; - information: DeviceInformation; on: boolean; humOn: boolean; From e3622dde5f257ca31c490e642de52ce4bbd13da4 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Fri, 12 Nov 2021 14:25:36 -0500 Subject: [PATCH 09/18] ecboee: add humidifier --- plugins/ecobee/package.json | 2 +- plugins/ecobee/src/main.ts | 79 ++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/plugins/ecobee/package.json b/plugins/ecobee/package.json index d012ee01a..317d05261 100644 --- a/plugins/ecobee/package.json +++ b/plugins/ecobee/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/ecobee", - "version": "0.0.4", + "version": "0.0.6", "description": "Ecobee API plugin", "author": "Scrypted", "license": "Apache", diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index ce3e90258..b550db736 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import sdk, { Device, DeviceInformation, ScryptedDeviceBase, OnOff, DeviceProvider, ScryptedDeviceType, ThermostatMode, Thermometer, HumiditySensor, TemperatureSetting, Settings, Setting, ScryptedInterface, Refresh, TemperatureUnit } from '@scrypted/sdk'; +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; // Convert Fahrenheit to Celsius, round to 2 decimal places @@ -42,12 +42,23 @@ function thermostatModeToEcobee(mode: ThermostatMode) { } } -class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Refresh, OnOff { +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; - humOn: boolean; constructor(nativeId: string, provider: EcobeeController, info: DeviceInformation) { super(nativeId); @@ -59,9 +70,32 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The var 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. */ @@ -120,11 +154,11 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } // humidifier status + let activeMode = HumidityMode.Off if (equipmentStatus.includes('humidifier')) { - this.humOn = true; - } else { - this.humOn = false; + activeMode = HumidityMode.Humidify } + this.humiditySetting = Object.assign(this.humiditySetting, { activeMode }); } /* revisionListChanged(): Compare a new revision list to the stored list, return true if changed @@ -178,6 +212,16 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The 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 { @@ -466,13 +510,26 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set // Get a list of all accessible devices const apiDevices = (await this.req('get', 'thermostat', null, { - json: `\{"selection":\{"selectionType":"registered","selectionMatch":""\}\}` + json: `\{"selection":\{"selectionType":"registered","selectionMatch":"","includeSettings": "true"\}\}` })).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(apiDevice); + + 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`, @@ -482,13 +539,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set manufacturer: apiDevice.modelNumber, serialNumber: apiDevice.identifier, }, - interfaces: [ - ScryptedInterface.HumiditySensor, - ScryptedInterface.Thermometer, - ScryptedInterface.TemperatureSetting, - ScryptedInterface.Refresh, - ScryptedInterface.OnOff, - ], + interfaces, } devices.push(device); From 6e2cd10619f16ddf6102c729d4dd7afba9e8f573 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 14 Nov 2021 16:26:06 -0500 Subject: [PATCH 10/18] ecobee: cleanup request json --- plugins/ecobee/src/main.ts | 41 ++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index b550db736..41f1f2828 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -116,9 +116,16 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The * */ async _refresh(): Promise { - const data = await this.provider.req('get', 'thermostatSummary', null, { - json: `\{"selection":\{"selectionType":"registered","selectionMatch":"${this.nativeId}","includeSettings": "true", "includeRuntime": "true", "includeEquipmentStatus": "true"\}\}` - }); + const json = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + includeSettings: "true", + includeRuntime: "true", + includeEquipmentStatus: "true", + } + } + const data = await this.provider.req('get', 'thermostatSummary', null, { json }); // Update equipmentStatus, trigger reload if changes detected this._updateEquipmentStatus(data.statusList[0].split(":")[1]); @@ -188,9 +195,16 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The * */ async reload(): Promise { - var data = (await this.provider.req('get', 'thermostat', {}, { - json: `\{"selection":\{"selectionType":"registered","selectionMatch":"${this.nativeId}","includeSettings": "true", "includeRuntime": "true", "includeEquipmentStatus": "true"\}\}` - })).thermostatList[0]; + const json = { + selection: { + selectionType: "registered", + selectionMatch: this.nativeId, + includeSettings: "true", + includeRuntime: "true", + includeEquipmentStatus: "true" + } + } + var data = (await this.provider.req('get', 'thermostat', {}, { json })).thermostatList[0]; // Set runtime values this.temperature = convertFtoC(Number(data.runtime.actualTemperature)/10) @@ -509,10 +523,15 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } // Get a list of all accessible devices - const apiDevices = (await this.req('get', 'thermostat', null, { - json: `\{"selection":\{"selectionType":"registered","selectionMatch":"","includeSettings": "true"\}\}` - })).thermostatList - this.console.log(`Discovered ${apiDevices.length} devices.`) + const json = { + selection: { + selectionType: "registered", + selectionMatch: "", + includeSettings: "true", + } + } + const apiDevices = (await this.req('get', 'thermostat', null, { json })).thermostatList + this.console.log(`Discovered ${apiDevices.length} devices.`); // Create a list of devices found from the API const devices: Device[] = []; @@ -562,4 +581,4 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } -export default new EcobeeController(); \ No newline at end of file +export default new EcobeeController(); From e34194fef9a846388c325c302bdb29741ec292f4 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 14 Nov 2021 16:56:57 -0500 Subject: [PATCH 11/18] ecobee: more cleanup --- plugins/ecobee/src/main.ts | 56 ++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 41f1f2828..19656da78 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -104,34 +104,26 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } /* 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(`refresh(${refreshInterface}, ${userInitiated}): ${new Date()}`) - this._refresh(); - } - - /* _refresh(): Poll from API '/thermostatSummary' endpoint for timestamp of last changes and compare to last check - * Updates equipmentStatus on each call - * - */ - async _refresh(): Promise { - const json = { - selection: { - selectionType: "registered", - selectionMatch: this.nativeId, - includeSettings: "true", - includeRuntime: "true", - includeEquipmentStatus: "true", - } + 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', null, { json }); + } + const data = await this.provider.req('get', 'thermostatSummary', null, { json }); - // Update equipmentStatus, trigger reload if changes detected - this._updateEquipmentStatus(data.statusList[0].split(":")[1]); - if (this._updateRevisionList(data.revisionList[0])) - await this.reload() - } + // 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 @@ -143,7 +135,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The */ _updateEquipmentStatus(equipmentStatus: string): void { equipmentStatus = equipmentStatus.toLowerCase() - this.console.log(`Equipment status: ${equipmentStatus}`); + this.console.log(` Current status: ${equipmentStatus}`); if (equipmentStatus.includes("heat")) // values: heatPump, heatPump[2-3], auxHeat[1-3] this.thermostatActiveMode = ThermostatMode.Heat; @@ -182,12 +174,12 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The // Compare each element, skip first 3 for (let i = 3; i < listItems.length; i++) { if (this.revisionList[i] !== oldList[i]) { - this.console.log(`Change detected: ${listItems[i]}`) + this.console.log(` Changes detected: ${listItems[i]}`) return true; } } - this.console.log("No changes detected"); + this.console.log(" Changes detected: none"); return false; } @@ -199,9 +191,9 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The selection: { selectionType: "registered", selectionMatch: this.nativeId, - includeSettings: "true", - includeRuntime: "true", - includeEquipmentStatus: "true" + includeSettings: true, + includeRuntime: true, + includeEquipmentStatus: true, } } var data = (await this.provider.req('get', 'thermostat', {}, { json })).thermostatList[0]; @@ -527,7 +519,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set selection: { selectionType: "registered", selectionMatch: "", - includeSettings: "true", + includeSettings: true, } } const apiDevices = (await this.req('get', 'thermostat', null, { json })).thermostatList @@ -536,7 +528,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set // Create a list of devices found from the API const devices: Device[] = []; for (let apiDevice of apiDevices) { - this.console.log(apiDevice); + this.console.log(` Discovered ${apiDevice.brand} ${apiDevice.modelNumber} ${apiDevice.name} (${apiDevice.identifier})`); const interfaces: ScryptedInterface[] = [ ScryptedInterface.Thermometer, From 82986a83eaff377902cf375209942eb0d897547f Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 14 Nov 2021 18:17:39 -0500 Subject: [PATCH 12/18] ecobee: rewrite API request --- plugins/ecobee/src/main.ts | 54 ++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 19656da78..275ad6e52 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +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; @@ -117,7 +117,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The includeEquipmentStatus: true, } } - const data = await this.provider.req('get', 'thermostatSummary', null, { json }); + const data = await this.provider.req('get', 'thermostatSummary', json) // Update equipmentStatus, trigger reload if changes detected this._updateEquipmentStatus(data.statusList[0].split(":")[1]); @@ -196,7 +196,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The includeEquipmentStatus: true, } } - var data = (await this.provider.req('get', 'thermostat', {}, { json })).thermostatList[0]; + const data = (await this.provider.req('get', 'thermostat', json)).thermostatList[0]; // Set runtime values this.temperature = convertFtoC(Number(data.runtime.actualTemperature)/10) @@ -245,7 +245,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } } - var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + const resp = await this.provider.req('post', 'thermostat', undefined, data); if (resp.status.code == 0) { this.console.log("setThermostatMode success") await this.reload(); @@ -276,7 +276,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The ] } - var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + const resp = await this.provider.req('post', 'thermostat', undefined, data) if (resp.status.code == 0) { this.console.log("setThermostatSetpoint success") await this.reload(); @@ -319,7 +319,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The ] } - var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + const resp = await this.provider.req('post', 'thermostat', undefined, data); if (resp.status.code == 0) { this.console.log("fanOff success") await this.reload(); @@ -352,7 +352,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The ] } - var resp = await this.provider.req('post', 'thermostat', data, { format: "json" }) + const resp = await this.provider.req('post', 'thermostat', undefined, data); if (resp.status.code == 0) { this.console.log("fanOn success") await this.reload(); @@ -476,28 +476,38 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } // Generic API request - async req(method, endpoint, data, params) { - const url = `https://${this.apiBaseUrl}/api/1/${endpoint}` - const options = { + async req( + method: string, + endpoint: string, + json?: any, + data?: any, + attempt?: number, + ): Promise { + if (attempt > 2) { + throw new Error(` request to ${method}:${endpoint} failed after ${attempt} retries`); + } + + // Configure API request + const config: AxiosRequestConfig = { + method, + url: `https://${this.apiBaseUrl}/api/1/${endpoint}`, headers: { Authorization: `Bearer ${this.access_token}`, }, - params: params + data, } - let resp + if (json) + config.params = { json }; + + // Make API request, recursively retry after token refresh try { - if (method === "post") { - resp = await axios.post(url, data, options) - } else { - resp = await axios[method](url, options); - } + return (await axios.request(config)).data; } catch (e) { - // really simple retry on failure - this.console.log("failed req(), refreshing token") + this.console.log(`req failed ${e}`) + // refresh token and retry request await this.refreshToken(); - resp = await axios[method](url, options); + return await this.req(method, endpoint, json, data, attempt++); } - return resp.data; } async discoverDevices(): Promise { @@ -522,7 +532,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set includeSettings: true, } } - const apiDevices = (await this.req('get', 'thermostat', null, { json })).thermostatList + 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 From 7f96d7bb3eff233e94e390d83d07ebad32d9344a Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Sun, 14 Nov 2021 18:29:56 -0500 Subject: [PATCH 13/18] ecobee: api retries/timeout --- plugins/ecobee/src/main.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 275ad6e52..99d1add28 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -2,6 +2,8 @@ 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) @@ -483,18 +485,20 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set data?: any, attempt?: number, ): Promise { - if (attempt > 2) { + if (attempt > API_RETRY) { throw new Error(` request to ${method}:${endpoint} failed after ${attempt} retries`); } // Configure API request const config: AxiosRequestConfig = { method, - url: `https://${this.apiBaseUrl}/api/1/${endpoint}`, + baseURL: `https://${this.apiBaseUrl}/api/1/`, + url: endpoint, headers: { Authorization: `Bearer ${this.access_token}`, }, data, + timeout: 10000, } if (json) config.params = { json }; From 0edeb99f6535e284bc07f9a9a14b4ee3764db9fd Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Tue, 16 Nov 2021 15:46:40 -0500 Subject: [PATCH 14/18] ecobee: support auto mode --- plugins/ecobee/src/main.ts | 79 +++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 99d1add28..57e335243 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -209,10 +209,10 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The // 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 'auto': - // TODO: need scrypted support for setpoint range? - break; case 'cool': this.thermostatSetpoint = convertFtoC(Number(data.runtime.desiredCool)/10) break; @@ -258,9 +258,14 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } async setThermostatSetpoint(degrees: number): Promise { - const degF = convertCtoF(degrees)*10; + 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", @@ -271,8 +276,8 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The type:"setHold", params:{ holdType: "nextTransition", - heatHoldTemp: degF, - coolHoldTemp: degF, + heatHoldTemp: degF*10, + coolHoldTemp: degF*10, } } ] @@ -289,13 +294,67 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The } async setThermostatSetpointHigh(high: number): Promise { - this.console.log(`setThermostatSetpointHigh ${high}`) - return; + 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 { - this.console.log(`setThermostatSetpointLow ${low}`) - return; + 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 { From 7127300322ae9070c30363616108fc8285ac0ef9 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Wed, 24 Nov 2021 12:10:10 -0500 Subject: [PATCH 15/18] ecobee: change var to const --- plugins/ecobee/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 57e335243..800389c60 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -69,7 +69,7 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The this.info = info; this.temperatureUnit = TemperatureUnit.F - var modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; + const modes: ThermostatMode[] = [ThermostatMode.Cool, ThermostatMode.Heat, ThermostatMode.Auto, ThermostatMode.Off]; this.thermostatAvailableModes = modes; let humModes: HumidityMode[] = [HumidityMode.Auto, HumidityMode.Humidify, HumidityMode.Off]; From 6bfb5e6b5e496bc89969b6bb7de1b2be4a43d35c Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Wed, 24 Nov 2021 12:19:15 -0500 Subject: [PATCH 16/18] ecobee: automate code/token process with alert messages --- plugins/ecobee/src/main.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index 800389c60..e1975e87d 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -476,6 +476,11 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } async putSetting(key: string, value: string): Promise { + // Try to get a code when a client ID is saved + if (key === "client_id") { + await this.getCode(); + } + this.storage.setItem(key, value.toString()); } @@ -492,7 +497,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set params: authParams, })).data - this.console.log(`Got code ${authData.ecobeePin}. Enter this in 'My Apps'.`) + this.log.a(`Got code ${authData.ecobeePin}. Enter this in 'My Apps' Ecobee portal. Then restart this app.`) this.ecobeeCode = authData.code; } @@ -583,7 +588,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } if (!this.access_token) { - this.log.a("You must complete the authentication process.") + await this.getToken(); return; } From 996f2d1eb880a8fd3072045c85bcd96b3d4f37d8 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Wed, 24 Nov 2021 12:24:48 -0500 Subject: [PATCH 17/18] ecobee: instructions on the console --- plugins/ecobee/src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index e1975e87d..f691f321d 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -584,6 +584,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set if (!this.clientId) { 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; } From d9a9cbcdd566baf90a8fa3f085a80cb6757b2890 Mon Sep 17 00:00:00 2001 From: Billy Zoellers Date: Wed, 24 Nov 2021 14:28:03 -0500 Subject: [PATCH 18/18] ecobee: fix alert/reload process, do not store access token --- plugins/ecobee/src/main.ts | 111 ++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 64 deletions(-) diff --git a/plugins/ecobee/src/main.ts b/plugins/ecobee/src/main.ts index f691f321d..3420e2650 100644 --- a/plugins/ecobee/src/main.ts +++ b/plugins/ecobee/src/main.ts @@ -426,20 +426,32 @@ class EcobeeThermostat extends ScryptedDeviceBase implements HumiditySensor, The class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Settings { devices = new Map(); - clientId: string; - apiBaseUrl: string; - ecobeeCode: string; access_token: string; - refresh_token: string; constructor() { super() - this.clientId = this.storage.getItem("client_id"); - this.apiBaseUrl = this.storage.getItem("api_base") || "api.ecobee.com"; - this.access_token = this.storage.getItem("access_token"); - this.refresh_token = this.storage.getItem("refresh_token"); + this.log.clearAlerts(); + if (!this.storage.getItem("api_base")) + this.storage.setItem("api_base", "api.ecobee.com"); - this.discoverDevices() + 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 { @@ -449,95 +461,78 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set title: "API Base URL", key: "api_base", description: "Customize the API base URL", - value: this.apiBaseUrl, + 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.clientId, - }, - { - group: "API Detail", - title: "Access Token", - key: "access_token", - readonly: true, - value: this.access_token || "You must complete the authentication process", - }, - { - group: "API Detail", - title: "Refresh Token", - key: "refresh_token", - readonly: true, - value: this.refresh_token || "You must complete the authentication process", + 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(); } - - this.storage.setItem(key, value.toString()); } // 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.apiBaseUrl}/authorize` + const authUrl = `https://${this.storage.getItem("api_base")}/authorize` const authParams = { response_type:'ecobeePin', scope: "smartWrite", - client_id: this.clientId, + 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.ecobeeCode = authData.code; + 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.apiBaseUrl}/token` + const tokenUrl = `https://${this.storage.getItem("api_base")}/token` const tokenParams = { grant_type:'ecobeePin', - code: this.ecobeeCode, - client_id: this.clientId, + 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.refresh_token = tokenData.refresh_token; - this.storage.setItem("access_token", this.access_token); - this.storage.setItem("refresh_token", this.refresh_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.apiBaseUrl}/token` + const tokenUrl = `https://${this.storage.getItem("api_base")}/token` const tokenParams = { grant_type:'refresh_token', - refresh_token: this.refresh_token, - client_id: this.clientId, + 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.refresh_token = tokenData.refresh_token; - this.storage.setItem("access_token", this.access_token); - this.storage.setItem("refresh_token", this.refresh_token); + this.storage.setItem("refresh_token", tokenData.refresh_token); console.log(`Refreshed access/refresh token`) } @@ -556,7 +551,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set // Configure API request const config: AxiosRequestConfig = { method, - baseURL: `https://${this.apiBaseUrl}/api/1/`, + baseURL: `https://${this.storage.getItem("api_base")}/api/1/`, url: endpoint, headers: { Authorization: `Bearer ${this.access_token}`, @@ -579,20 +574,6 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set } async discoverDevices(): Promise { - // Check that required properties exist - this.log.clearAlerts(); - - if (!this.clientId) { - 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.access_token) { - await this.getToken(); - return; - } - // Get a list of all accessible devices const json = { selection: { @@ -619,7 +600,7 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set ] if (apiDevice.settings.hasHumidifier) interfaces.push(ScryptedInterface.HumiditySetting); - + const device: Device = { nativeId: apiDevice.identifier, name: `${apiDevice.modelNumber} thermostat`, @@ -632,18 +613,20 @@ class EcobeeController extends ScryptedDeviceBase implements DeviceProvider, Set interfaces, } devices.push(device); - - let providerDevice = this.devices.get(device.nativeId); - if (!providerDevice) { - providerDevice = new EcobeeThermostat(device.nativeId, this, device.info) - this.devices.set(apiDevice.identifier, providerDevice) - } } // 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) {