Merge pull request #58 from billyzoellers/ecobee-plugin

Ecobee plugin
This commit is contained in:
Koushik Dutta
2021-11-24 16:38:45 -05:00
committed by GitHub
9 changed files with 1012 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/*.map

4
plugins/ecobee/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/

22
plugins/ecobee/.vscode/launch.json vendored Normal file
View File

@@ -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": [
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "pwa-node"
}
]
}

4
plugins/ecobee/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"scrypted.debugHost": "127.0.0.1",
}

20
plugins/ecobee/.vscode/tasks.json vendored Normal file
View File

@@ -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}",
},
]
}

15
plugins/ecobee/README.md Normal file
View File

@@ -0,0 +1,15 @@
# An Ecobee thermostat plugin
## npm commands
* npm run scrypted-webpack
* npm run scrypted-deploy <ipaddress>
* npm run scrypted-debug <ipaddress>
## 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.

263
plugins/ecobee/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

638
plugins/ecobee/src/main.ts Normal file
View File

@@ -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<Setting[]> {
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<void> {
this.storage.setItem(key, value.toString());
}
/*
* Get the recommended refresh/poll frequency in seconds for this device.
*/
async getRefreshFrequency(): Promise<number> {
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<void> {
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<void> {
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<void> {
this.console.log(`setHumidity ${humidity.mode} ${humidity.humidifierSetpoint}: not yet supported`);
}
async setThermostatMode(mode: ThermostatMode): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, any>();
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<void> {
// 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<Setting[]> {
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<void> {
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<any> {
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<void> {
// 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();