homebridge-ecobee-status
Version:
Homebridge plugin to control Ecobee thermostat Home/Away/Sleep status through HomeKit security system interface
286 lines • 14.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AwaySwitchAccessory = void 0;
const axios_1 = __importDefault(require("axios"));
const auth_token_refresh_1 = require("./auth-token-refresh");
const network_retry_1 = require("./network-retry");
/**
* Platform Accessory
* An instance of this class is created for each accessory your platform registers
* Each accessory may expose multiple services of different service types.
*/
class AwaySwitchAccessory {
constructor(platform, accessory) {
this.platform = platform;
this.accessory = accessory;
// Define constants for climate states
this.CLIMATE_HOME = 'home';
this.CLIMATE_AWAY = 'away';
this.CLIMATE_SLEEP = 'sleep';
// Initialize NetworkRetry with appropriate settings for API calls
this.networkRetry = new network_retry_1.NetworkRetry({
maxAttempts: 8,
initialDelay: 15000, // 15 seconds
maxDelay: 60000, // 1 minute
backoffFactor: 2,
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH'],
});
// set accessory information
this.accessory.getService(this.platform.Service.AccessoryInformation)
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Ecobee')
.setCharacteristic(this.platform.Characteristic.Model, 'Climate Controller')
.setCharacteristic(this.platform.Characteristic.SerialNumber, 'ECOBEEAWAY1');
// Use SecuritySystem service instead of Switch
this.service = this.accessory.getService(this.platform.Service.SecuritySystem)
|| this.accessory.addService(this.platform.Service.SecuritySystem);
// Set the service name
this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.displayName);
// Register handlers for the SecuritySystemTargetState Characteristic
this.service.getCharacteristic(this.platform.Characteristic.SecuritySystemTargetState)
.setProps({
validValues: [
this.platform.Characteristic.SecuritySystemTargetState.STAY_ARM, // Home
this.platform.Characteristic.SecuritySystemTargetState.AWAY_ARM, // Away
this.platform.Characteristic.SecuritySystemTargetState.NIGHT_ARM, // Sleep
],
})
.on('set', this.setTargetState.bind(this))
.on('get', this.getTargetState.bind(this));
// Register handlers for the SecuritySystemCurrentState Characteristic
this.service.getCharacteristic(this.platform.Characteristic.SecuritySystemCurrentState)
.on('get', this.getCurrentState.bind(this));
// Poll for updates
const pollingMinutes = this.platform.config.statusPollingMinutes || 60;
const pollingInterval = pollingMinutes * 60 * 1000;
setInterval(async () => {
try {
const apiStatus = await this.checkStatusFromAPI();
const currentState = this.mapClimateToSecurityState(apiStatus, false); // explicit false for current state
const targetState = this.mapClimateToSecurityState(apiStatus, true); // explicit true for target state
this.service.updateCharacteristic(this.platform.Characteristic.SecuritySystemCurrentState, currentState);
this.service.updateCharacteristic(this.platform.Characteristic.SecuritySystemTargetState, targetState);
this.platform.log.debug('Pushed updated current state to HomeKit:', apiStatus);
}
catch (error) {
this.platform.log.debug('Failed to poll status:', error);
}
}, pollingInterval);
}
/**
* Maps climate mode to HomeKit security system state
*/
mapClimateToSecurityState(climate, isTarget = false) {
switch (climate) {
case this.CLIMATE_AWAY:
return isTarget
? this.platform.Characteristic.SecuritySystemTargetState.AWAY_ARM
: this.platform.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
case this.CLIMATE_SLEEP:
return isTarget
? this.platform.Characteristic.SecuritySystemTargetState.NIGHT_ARM
: this.platform.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
case this.CLIMATE_HOME:
default:
return isTarget
? this.platform.Characteristic.SecuritySystemTargetState.STAY_ARM
: this.platform.Characteristic.SecuritySystemCurrentState.STAY_ARM;
}
}
/**
* Maps HomeKit security system state to climate mode
*/
mapSecurityToClimate(state) {
switch (state) {
case this.platform.Characteristic.SecuritySystemTargetState.AWAY_ARM:
return this.CLIMATE_AWAY;
case this.platform.Characteristic.SecuritySystemTargetState.NIGHT_ARM:
return this.CLIMATE_SLEEP;
case this.platform.Characteristic.SecuritySystemTargetState.STAY_ARM:
default:
return this.CLIMATE_HOME;
}
}
/**
* Helper method to handle API requests with retry logic
*/
async makeEcobeeRequest(request, operationType) {
return this.networkRetry.execute(request, this.platform.log, `Ecobee API ${operationType}`);
}
/**
* Handle SET requests from HomeKit
*/
async setTargetState(value, callback) {
var _a, _b, _c;
try {
const targetState = value;
const climateRef = this.mapSecurityToClimate(targetState);
const needsRefresh = auth_token_refresh_1.AuthTokenManager.getInstance().isExpired();
if (needsRefresh) {
const refreshedToken = await auth_token_refresh_1.AuthTokenManager.getInstance().renewAuthToken();
if (!refreshedToken) {
throw new Error('Failed to refresh expired auth token');
}
}
const authToken = auth_token_refresh_1.AuthTokenManager.getInstance().authToken;
const selectionMatch = this.platform.config.thermostatSerialNumbers || '';
const selectionType = selectionMatch ? 'thermostats' : 'registered';
// Determine if we should use indefinite hold based on the state
let useIndefiniteHold = false;
switch (climateRef) {
case this.CLIMATE_HOME:
useIndefiniteHold = (_a = this.platform.config.homeIndefiniteHold) !== null && _a !== void 0 ? _a : false;
break;
case this.CLIMATE_AWAY:
useIndefiniteHold = (_b = this.platform.config.awayIndefiniteHold) !== null && _b !== void 0 ? _b : true;
break;
case this.CLIMATE_SLEEP:
useIndefiniteHold = (_c = this.platform.config.sleepIndefiniteHold) !== null && _c !== void 0 ? _c : true;
break;
}
let requestBody;
if (useIndefiniteHold) {
// Use setHold with indefinite hold
requestBody = {
'selection': {
'selectionType': selectionType,
'selectionMatch': selectionMatch,
},
'functions': [
{
'type': 'setHold',
'params': {
'holdType': 'indefinite',
'holdClimateRef': climateRef,
},
},
],
};
}
else {
// Use resumeProgram
requestBody = {
'selection': {
'selectionType': selectionType,
'selectionMatch': selectionMatch,
},
'functions': [
{
'type': 'resumeProgram',
'params': {
'resumeAll': false,
},
},
],
};
}
const response = await this.makeEcobeeRequest(() => axios_1.default.post('https://api.ecobee.com/1/thermostat?format=json', requestBody, { headers: { 'Authorization': 'Bearer ' + authToken } }), `${climateRef.toUpperCase()} mode set`);
this.platform.log.info(`Set Ecobee to ${climateRef} with result: ${JSON.stringify(response.data)}`);
if (response.data.status.code === 0) {
// Add a small delay to allow the thermostat to process the change
await new Promise(resolve => setTimeout(resolve, 2000));
// Double-check the current status to ensure it took effect
const currentStatus = await this.checkStatusFromAPI();
if (currentStatus !== climateRef && useIndefiniteHold) {
this.platform.log.warn(`${climateRef.toUpperCase()} mode set succeeded but status check shows different state:`, currentStatus);
}
this.service.updateCharacteristic(this.platform.Characteristic.SecuritySystemCurrentState, targetState);
this.platform.log.debug(`Successfully updated to ${climateRef.toUpperCase()} state`);
}
else {
throw new Error(`Failed to set ${climateRef.toUpperCase()} mode: ${JSON.stringify(response.data)}`);
}
callback(null);
}
catch (error) {
this.platform.log.error('Failed to set target state:', error);
callback(error);
}
}
/**
* Handle GET requests for target state
*/
async getTargetState(callback) {
try {
const apiStatus = await this.checkStatusFromAPI();
const state = this.mapClimateToSecurityState(apiStatus, true); // Add true for target state
this.platform.log.debug('Get Target State ->', state);
callback(null, state);
}
catch (error) {
this.platform.log.error('Failed to get target state:', error);
callback(error);
}
}
/**
* Handle GET requests for current state
*/
async getCurrentState(callback) {
try {
const apiStatus = await this.checkStatusFromAPI();
const state = this.mapClimateToSecurityState(apiStatus, false); // Add false for current state
this.platform.log.debug('Get Current State ->', state);
callback(null, state);
}
catch (error) {
this.platform.log.error('Failed to get current state:', error);
callback(error);
}
}
/**
* Check the current climate status from the Ecobee API
*/
async checkStatusFromAPI() {
try {
const needsRefresh = auth_token_refresh_1.AuthTokenManager.getInstance().isExpired();
if (needsRefresh) {
await auth_token_refresh_1.AuthTokenManager.getInstance().renewAuthToken();
}
const authToken = auth_token_refresh_1.AuthTokenManager.getInstance().authToken;
try {
// Use configured thermostat serial numbers when provided so we query the correct thermostat
const selectionMatch = this.platform.config.thermostatSerialNumbers || '';
const selectionType = selectionMatch ? 'thermostats' : 'registered';
const body = {
selection: {
selectionType,
selectionMatch,
includeEvents: true,
},
};
// Log which selection is being queried so users with multiple thermostats can see what's polled
this.platform.log.debug(`Querying Ecobee thermostats - selectionType=${selectionType}, selectionMatch=${selectionMatch}`);
const queryRequest = await this.makeEcobeeRequest(() => axios_1.default.get('https://api.ecobee.com/1/thermostat?format=json&body=' + encodeURIComponent(JSON.stringify(body)), { headers: { 'Authorization': 'Bearer ' + authToken } }), 'status check');
const queryData = queryRequest.data;
if (!queryData || !queryData.thermostatList) {
this.platform.log.error('Unexpected query data structure:', JSON.stringify(queryData));
return this.CLIMATE_HOME;
}
// Use the first thermostat from the response.
// When thermostatSerialNumbers is configured, only the requested thermostat is returned.
// When not configured (registered selection), we use the first thermostat on the account.
const thermostat = queryData.thermostatList[0];
const events = thermostat.events || [];
if (events.length > 0) {
const mostRecentEvent = events[0];
return mostRecentEvent.holdClimateRef || this.CLIMATE_HOME;
}
else {
return this.CLIMATE_HOME;
}
}
catch (error) {
this.platform.log.error('Failed to check status:', error);
return this.CLIMATE_HOME;
}
}
catch (error) {
this.platform.log.error('Error in checkStatusFromAPI:', error);
return this.CLIMATE_HOME;
}
}
}
exports.AwaySwitchAccessory = AwaySwitchAccessory;
//# sourceMappingURL=awaySwitchAccessory.js.map