@homebridge-plugins/homebridge-tado
Version:
Homebridge plugin for controlling tado° devices.
1,237 lines (1,005 loc) • 62.3 kB
JavaScript
import Logger from '../helper/logger.js';
import moment from 'moment';
import { writeFile } from 'fs/promises';
import { join } from "path";
import { randomUUID } from 'crypto';
const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
const helpers = {};
export default (api, accessories, config, tado, telegram) => {
//init helper variables for current home scope
if (!helpers[config.homeId]) {
helpers[config.homeId] = {
activeSettingStateRuns: {},
tasksInitialized: false,
lastGetStates: 0,
lastPersistZoneStates: 0,
persistPromise: Promise.resolve(),
updateZonesRunning: false,
updateZonesNextQueued: false,
delayTimer: {},
refreshHistoryHandlers: [],
statesIntervalTime: Math.max(config.polling, 30) * 1000,
storagePath: api.user.storagePath(),
}
}
function settingStates() {
return Object.keys(helpers[config.homeId].activeSettingStateRuns).length > 0;
}
async function setStates(accessory, accs, target, value) {
let zoneUpdated = false;
accessories = accs.filter((acc) => acc && acc.context.config.homeName === config.homeName);
const runId = randomUUID();
try {
helpers[config.homeId].activeSettingStateRuns[runId] = true;
value = typeof value === 'number' ? parseFloat(value.toFixed(2)) : value;
Logger.info(target + ': ' + value, accessory.displayName);
switch (accessory.context.config.subtype) {
case 'zone-thermostat':
case 'zone-heatercooler':
case 'zone-heatercooler-boiler':
case 'zone-heatercooler-ac': {
let power, temp, clear;
let service =
accessory.getService(api.hap.Service.HeaterCooler) || accessory.getService(api.hap.Service.Thermostat);
let targetTempCharacteristic = accessory.getService(api.hap.Service.HeaterCooler)
? api.hap.Characteristic.HeatingThresholdTemperature
: api.hap.Characteristic.TargetTemperature;
if (
accessory.context.config.subtype !== 'zone-heatercooler-boiler' &&
accessory.context.config.subtype !== 'zone-heatercooler-ac' &&
!accessory.context.config.autoOffDelay &&
accessory.context.config.delaySwitch &&
accessory.context.delaySwitch &&
accessory.context.delayTimer &&
value < 5
) {
if (value === 0) {
if (helpers[config.homeId].delayTimer[accessory.displayName]) {
Logger.info('Resetting delay timer', accessory.displayName);
clearTimeout(helpers[config.homeId].delayTimer[accessory.displayName]);
helpers[config.homeId].delayTimer[accessory.displayName] = null;
}
power = 'OFF';
temp = parseFloat(service.getCharacteristic(targetTempCharacteristic).value.toFixed(2));
let mode =
accessory.context.config.mode === 'TIMER'
? (accessory.context.config.modeTimer || 30) * 60
: accessory.context.config.mode;
// Use AC-specific overlay for AIR_CONDITIONING zones
if (accessory.context.config.type === 'AIR_CONDITIONING') {
zoneUpdated = true;
await tado.setACZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
'COOL', // Default AC mode for OFF state
temp,
null, // No fan speed for AC units
'OFF', // Default swing
mode,
accessory.context.config.temperatureUnit
);
} else {
zoneUpdated = true;
await tado.setZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
temp,
mode,
accessory.context.config.temperatureUnit
);
}
} else {
let mode =
accessory.context.config.mode === 'TIMER'
? (accessory.context.config.modeTimer || 30) * 60
: accessory.context.config.mode;
let timer = accessory.context.delayTimer;
let tarState = value === 1 ? 'HEAT' : 'AUTO';
if (helpers[config.homeId].delayTimer[accessory.displayName]) {
clearTimeout(helpers[config.homeId].delayTimer[accessory.displayName]);
helpers[config.homeId].delayTimer[accessory.displayName] = null;
}
Logger.info('Wait ' + timer + ' seconds before switching state', accessory.displayName);
helpers[config.homeId].delayTimer[accessory.displayName] = setTimeout(async () => {
Logger.info('Delay timer finished, switching state to ' + tarState, accessory.displayName);
//targetState
clear = value === 3;
power = 'ON';
temp = parseFloat(service.getCharacteristic(targetTempCharacteristic).value.toFixed(2));
if (
clear ||
(value &&
accessory.context.config.mode === 'AUTO' &&
accessory.context.config.subtype.includes('heatercooler'))
) {
zoneUpdated = true;
await tado.clearZoneOverlay(config.homeId, accessory.context.config.zoneId);
return;
}
mode =
!value &&
accessory.context.config.mode === 'AUTO' &&
accessory.context.config.subtype.includes('heatercooler')
? 'MANUAL'
: mode;
// Use AC-specific overlay for AIR_CONDITIONING zones
if (accessory.context.config.type === 'AIR_CONDITIONING') {
let acMode = value === 1 ? 'HEAT' : value === 2 ? 'COOL' : 'COOL';
zoneUpdated = true;
await tado.setACZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
acMode,
temp,
null, // No fan speed for AC units
'OFF', // Default swing
mode,
accessory.context.config.temperatureUnit
);
} else {
zoneUpdated = true;
await tado.setZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
temp,
mode,
accessory.context.config.temperatureUnit
);
}
helpers[config.homeId].delayTimer[accessory.displayName] = null;
}, timer * 1000);
}
} else {
let mode =
accessory.context.config.mode === 'TIMER'
? (accessory.context.config.modeTimer || 30) * 60
: accessory.context.config.mode;
if ([0, 1, 3].includes(value)) {
//targetState
clear = value === 3;
power = value ? 'ON' : 'OFF';
temp = parseFloat(service.getCharacteristic(targetTempCharacteristic).value.toFixed(2));
if (
clear ||
(value &&
accessory.context.config.mode === 'CUSTOM' &&
accessory.context.config.subtype.includes('heatercooler'))
) {
zoneUpdated = true;
await tado.clearZoneOverlay(config.homeId, accessory.context.config.zoneId);
return;
}
mode =
!value &&
accessory.context.config.mode === 'CUSTOM' &&
accessory.context.config.subtype.includes('heatercooler')
? 'MANUAL'
: mode;
} else {
//temp
power = 'ON';
temp = parseFloat(value.toFixed(2));
}
// Use AC-specific overlay for AIR_CONDITIONING zones
if (accessory.context.config.type === 'AIR_CONDITIONING') {
// Map Apple Home target state to tado AC mode
let acMode = 'COOL'; // Default to COOL
// Get current target state from HeaterCooler service for proper mode detection
let heaterCoolerService = accessory.getService(api.hap.Service.HeaterCooler);
if (heaterCoolerService) {
let targetState = heaterCoolerService.getCharacteristic(api.hap.Characteristic.TargetHeaterCoolerState).value;
// Map Apple Home target states to tado AC modes
// 1 = Heat, 2 = Cool, 3 = Auto
switch (targetState) {
case 1:
acMode = 'HEAT';
break;
case 2:
acMode = 'COOL';
break;
case 3:
default:
acMode = 'AUTO';
break;
}
// For temperature changes, use the appropriate threshold characteristic
if (![0, 1, 3].includes(value)) {
// This is a temperature change, use the appropriate temperature based on mode
if (acMode === 'HEAT' && heaterCoolerService.testCharacteristic(api.hap.Characteristic.HeatingThresholdTemperature)) {
temp = parseFloat(heaterCoolerService.getCharacteristic(api.hap.Characteristic.HeatingThresholdTemperature).value.toFixed(2));
} else if (acMode === 'COOL' && heaterCoolerService.testCharacteristic(api.hap.Characteristic.CoolingThresholdTemperature)) {
temp = parseFloat(heaterCoolerService.getCharacteristic(api.hap.Characteristic.CoolingThresholdTemperature).value.toFixed(2));
} else {
// Fallback to cooling threshold, then heating threshold
if (heaterCoolerService.testCharacteristic(api.hap.Characteristic.CoolingThresholdTemperature)) {
temp = parseFloat(heaterCoolerService.getCharacteristic(api.hap.Characteristic.CoolingThresholdTemperature).value.toFixed(2));
} else if (heaterCoolerService.testCharacteristic(api.hap.Characteristic.HeatingThresholdTemperature)) {
temp = parseFloat(heaterCoolerService.getCharacteristic(api.hap.Characteristic.HeatingThresholdTemperature).value.toFixed(2));
}
}
} else {
// This is a state change - use the mapped AC mode
}
}
zoneUpdated = true;
await tado.setACZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
acMode,
temp,
null, // No fan speed for AC units
'OFF', // Default swing
mode,
accessory.context.config.temperatureUnit
);
} else {
zoneUpdated = true;
await tado.setZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
temp,
mode,
accessory.context.config.temperatureUnit
);
}
}
break;
}
case 'zone-switch':
case 'zone-faucet': {
let faucetService = accessory.getService(api.hap.Service.Faucet);
let temp = null;
let power = value ? 'ON' : 'OFF';
if (faucetService) faucetService.getCharacteristic(this.api.hap.Characteristic.InUse).updateValue(value);
let mode =
accessory.context.config.mode === 'TIMER'
? (accessory.context.config.modeTimer || 30) * 60
: accessory.context.config.mode;
// Use AC-specific overlay for AIR_CONDITIONING zones
if (accessory.context.config.type === 'AIR_CONDITIONING') {
zoneUpdated = true;
await tado.setACZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
'COOL', // Default AC mode for switch/faucet
temp,
null, // No fan speed for AC units
'OFF', // Default swing
mode,
accessory.context.config.temperatureUnit
);
} else {
zoneUpdated = true;
await tado.setZoneOverlay(
config.homeId,
accessory.context.config.zoneId,
power,
temp,
mode,
accessory.context.config.temperatureUnit
);
}
break;
}
case 'extra-plock':
case 'extra-plockswitch': {
let targetState;
if (accessory.context.config.subtype === 'extra-plockswitch') {
let serviceHomeSwitch = accessory.getServiceById(api.hap.Service.Switch, 'HomeSwitch');
let serviceAwaySwitch = accessory.getServiceById(api.hap.Service.Switch, 'AwaySwitch');
let characteristic = api.hap.Characteristic.On;
if (value) {
if (target === 'Home') {
targetState = 'HOME';
serviceAwaySwitch.getCharacteristic(characteristic).updateValue(false);
} else {
targetState = 'AWAY';
serviceHomeSwitch.getCharacteristic(characteristic).updateValue(false);
}
} else {
targetState = 'AUTO';
serviceAwaySwitch.getCharacteristic(characteristic).updateValue(false);
serviceHomeSwitch.getCharacteristic(characteristic).updateValue(false);
}
} else {
let serviceSecurity = accessory.getService(api.hap.Service.SecuritySystem);
let characteristicCurrent = api.hap.Characteristic.SecuritySystemCurrentState;
serviceSecurity.getCharacteristic(characteristicCurrent).updateValue(value);
if (value === 1) {
//away
targetState = 'AWAY';
} else if (value === 3) {
//off
targetState = 'AUTO';
} else {
//at home
targetState = 'HOME';
}
}
await tado.setPresenceLock(config.homeId, targetState);
break;
}
case 'zone-window-switch': {
let zoneId = target.split('-');
zoneId = zoneId[zoneId.length - 1];
await tado.setWindowDetection(config.homeId, zoneId, value, 3600);
await tado.setOpenWindowMode(config.homeId, zoneId, value);
break;
}
case 'extra-childswitch': {
await tado.setChildLock(target, value);
break;
}
case 'extra-cntrlswitch': {
if (target === 'Dummy') return;
const heatAccessories = accessories.filter((acc) => acc && acc.context.config.type === 'HEATING');
const rooms = accessory.context.config.rooms
.map((room) => {
return {
id: room.id,
power: target === 'Central' ? (value ? 'ON' : 'OFF') : target === 'Off' ? 'OFF' : 'ON',
maxTempInCelsius: target === 'Central' ? (value ? 25 : 0) : target === 'Off' ? false : 25,
termination: ['MANUAL', 'AUTO', 'TIMER'].includes(room.mode) ? room.mode : 'MANUAL',
timer:
['MANUAL', 'AUTO', 'TIMER'].includes(room.mode) && room.mode === 'TIMER'
? room.modeTimer && room.modeTimer >= 1
? room.modeTimer * 60
: 1800 //30min
: false,
};
})
.filter((room) => room);
if (value) {
if (target === 'Central' || target === 'Shedule') {
const roomIds = accessory.context.config.rooms
.map((room) => {
return room.id;
})
.filter((id) => id);
await tado.resumeShedule(config.homeId, roomIds);
//Turn all back to AUTO/ON
heatAccessories.forEach((acc) => {
let serviceThermostat = acc.getService(api.hap.Service.Thermostat);
let serviceHeaterCooler = acc.getService(api.hap.Service.HeaterCooler);
if (serviceThermostat) {
let characteristicTarget = api.hap.Characteristic.TargetHeatingCoolingState;
serviceThermostat.getCharacteristic(characteristicTarget).updateValue(3);
} else if (serviceHeaterCooler) {
let characteristicActive = api.hap.Characteristic.Active;
serviceHeaterCooler.getCharacteristic(characteristicActive).updateValue(1);
}
});
accessory
.getServiceById(api.hap.Service.Switch, 'Central')
.getCharacteristic(api.hap.Characteristic.On)
.updateValue(true);
return;
}
if (target === 'Boost') {
//Turn On All & Max temp & Central Switch
heatAccessories.forEach((acc) => {
let serviceThermostat = acc.getService(api.hap.Service.Thermostat);
let serviceHeaterCooler = acc.getService(api.hap.Service.HeaterCooler);
if (serviceThermostat) {
let characteristicCurrent = api.hap.Characteristic.CurrentHeatingCoolingState;
let characteristicTarget = api.hap.Characteristic.TargetHeatingCoolingState;
let characteristicTargetTemp = api.hap.Characteristic.TargetTemperature;
let maxTemp = serviceThermostat.getCharacteristic(characteristicTargetTemp).props.maxValue;
serviceThermostat.getCharacteristic(characteristicCurrent).updateValue(1);
serviceThermostat.getCharacteristic(characteristicTarget).updateValue(1);
serviceThermostat.getCharacteristic(characteristicTargetTemp).updateValue(maxTemp);
} else if (serviceHeaterCooler) {
let characteristicActive = api.hap.Characteristic.Active;
let characteristicTargetTempHeat = api.hap.Characteristic.HeatingThresholdTemperature;
let maxTemp = serviceHeaterCooler.getCharacteristic(characteristicTargetTempHeat).props.maxValue; //same for cool
serviceHeaterCooler.getCharacteristic(characteristicActive).updateValue(1);
serviceHeaterCooler.getCharacteristic(characteristicTargetTempHeat).updateValue(maxTemp);
}
});
accessory
.getServiceById(api.hap.Service.Switch, 'Central')
.getCharacteristic(api.hap.Characteristic.On)
.updateValue(true);
}
if (target === 'Off') {
//Turn Off All && Central Switch
heatAccessories.forEach((acc) => {
let serviceThermostat = acc.getService(api.hap.Service.Thermostat);
let serviceHeaterCooler = acc.getService(api.hap.Service.HeaterCooler);
if (serviceThermostat) {
let characteristicCurrent = api.hap.Characteristic.CurrentHeatingCoolingState;
let characteristicTarget = api.hap.Characteristic.TargetHeatingCoolingState;
serviceThermostat.getCharacteristic(characteristicCurrent).updateValue(0);
serviceThermostat.getCharacteristic(characteristicTarget).updateValue(0);
} else if (serviceHeaterCooler) {
let characteristicActive = api.hap.Characteristic.Active;
serviceHeaterCooler.getCharacteristic(characteristicActive).updateValue(0);
}
});
accessory
.getServiceById(api.hap.Service.Switch, 'Central')
.getCharacteristic(api.hap.Characteristic.On)
.updateValue(false);
}
if (target !== 'Central') {
setTimeout(() => {
accessory
.getServiceById(api.hap.Service.Switch, 'Central' + target)
.getCharacteristic(api.hap.Characteristic.On)
.updateValue(false);
}, 500);
}
} else {
if (target !== 'Central') return;
//Turn Off All && Central Switch
heatAccessories.forEach((acc) => {
let serviceThermostat = acc.getService(api.hap.Service.Thermostat);
let serviceHeaterCooler = acc.getService(api.hap.Service.HeaterCooler);
if (serviceThermostat) {
let characteristicCurrent = api.hap.Characteristic.CurrentHeatingCoolingState;
let characteristicTarget = api.hap.Characteristic.TargetHeatingCoolingState;
serviceThermostat.getCharacteristic(characteristicCurrent).updateValue(0);
serviceThermostat.getCharacteristic(characteristicTarget).updateValue(0);
} else if (serviceHeaterCooler) {
let characteristicActive = api.hap.Characteristic.Active;
serviceHeaterCooler.getCharacteristic(characteristicActive).updateValue(0);
}
});
accessory
.getServiceById(api.hap.Service.Switch, 'Central')
.getCharacteristic(api.hap.Characteristic.On)
.updateValue(false);
}
await tado.switchAll(config.homeId, rooms);
break;
}
default:
Logger.warn(
'Unknown accessory type! [' + accessory.context.config.subtype + '] ' + target + ': ' + value,
accessory.displayName
);
break;
}
} catch (error) {
Logger.error(`Failed to set states: ${error.message || error}`);
} finally {
delete helpers[config.homeId].activeSettingStateRuns[runId];
//update zones to ensure correct state in Apple Home
const timeSinceLastGetStates = helpers[config.homeId].lastGetStates === 0 ? 0 : (Date.now() - helpers[config.homeId].lastGetStates);
const statesIntervalTimeLeft = helpers[config.homeId].statesIntervalTime - timeSinceLastGetStates;
if (!settingStates() && zoneUpdated && statesIntervalTimeLeft > (10 * 1000)) await updateZones();
}
}
async function changedStates(accessory, historyService, replacer, value) {
if (value.oldValue !== value.newValue) {
switch (accessory.context.config.subtype) {
case 'zone-thermostat': {
let currentState = accessory
.getService(api.hap.Service.Thermostat)
.getCharacteristic(api.hap.Characteristic.CurrentHeatingCoolingState).value;
let targetState = accessory
.getService(api.hap.Service.Thermostat)
.getCharacteristic(api.hap.Characteristic.TargetHeatingCoolingState).value;
let currentTemp = accessory
.getService(api.hap.Service.Thermostat)
.getCharacteristic(api.hap.Characteristic.CurrentTemperature).value;
let targetTemp = accessory
.getService(api.hap.Service.Thermostat)
.getCharacteristic(api.hap.Characteristic.TargetTemperature).value;
let valvePos =
currentTemp <= targetTemp &&
currentState !== api.hap.Characteristic.CurrentHeatingCoolingState.OFF &&
targetState !== api.hap.Characteristic.TargetHeatingCoolingState.OFF
? Math.round(targetTemp - currentTemp >= 5 ? 100 : (targetTemp - currentTemp) * 20)
: 0;
if (historyService) historyService.addEntry({
time: moment().unix(),
currentTemp: currentTemp,
setTemp: targetTemp,
valvePosition: valvePos,
});
break;
}
case 'zone-heatercooler':
case 'zone-heatercooler-boiler':
case 'zone-heatercooler-ac': {
let currentState = accessory
.getService(api.hap.Service.HeaterCooler)
.getCharacteristic(api.hap.Characteristic.CurrentHeaterCoolerState).value;
let currentTemp = accessory
.getService(api.hap.Service.HeaterCooler)
.getCharacteristic(api.hap.Characteristic.CurrentTemperature).value;
let targetTemp = accessory
.getService(api.hap.Service.HeaterCooler)
.getCharacteristic(api.hap.Characteristic.HeatingThresholdTemperature).value;
let valvePos =
currentTemp <= targetTemp && currentState !== 0
? Math.round(targetTemp - currentTemp >= 5 ? 100 : (targetTemp - currentTemp) * 20)
: 0;
if (historyService) historyService.addEntry({
time: moment().unix(),
currentTemp: currentTemp,
setTemp: targetTemp,
valvePosition: valvePos,
});
break;
}
case 'zone-window-contact': {
if (value.newValue) {
accessory.context.timesOpened = accessory.context.timesOpened || 0;
accessory.context.timesOpened += 1;
let lastActivation = moment().unix() - historyService.getInitialTime();
let closeDuration = moment().unix() - historyService.getInitialTime();
accessory
.getService(api.hap.Service.ContactSensor)
.getCharacteristic(api.hap.Characteristic.LastActivation)
.updateValue(lastActivation);
accessory
.getService(api.hap.Service.ContactSensor)
.getCharacteristic(api.hap.Characteristic.TimesOpened)
.updateValue(accessory.context.timesOpened);
accessory
.getService(api.hap.Service.ContactSensor)
.getCharacteristic(api.hap.Characteristic.ClosedDuration)
.updateValue(closeDuration);
} else {
let openDuration = moment().unix() - historyService.getInitialTime();
accessory
.getService(api.hap.Service.ContactSensor)
.getCharacteristic(api.hap.Characteristic.ClosedDuration)
.updateValue(openDuration);
}
if (historyService) historyService.addEntry({ time: moment().unix(), status: value.newValue ? 1 : 0 });
let dest = value.newValue ? 'opened' : 'closed';
replacer = replacer.split(accessory.context.config.homeName + ' ')[1];
let additional = accessory.context.config.homeName;
if (telegram) telegram.send('openWindow', dest, replacer, additional);
break;
}
case 'presence-occupancy':
case 'presence-motion': {
if (historyService) {
let lastActivation = moment().unix() - historyService.getInitialTime();
accessory
.getService(api.hap.Service.MotionSensor)
.getCharacteristic(api.hap.Characteristic.LastActivation)
.updateValue(lastActivation);
if (historyService) historyService.addEntry({ time: moment().unix(), status: value.newValue ? 1 : 0 });
}
let dest;
replacer = replacer.split(accessory.context.config.homeName + ' ')[1];
let additional = accessory.context.config.homeName;
if (value.newValue) {
dest = accessory.displayName === accessory.context.config.homeName + ' Anyone' ? 'anyone_in' : 'user_in';
} else {
dest = accessory.displayName === accessory.context.config.homeName + ' Anyone' ? 'anyone_out' : 'user_out';
}
if (telegram) telegram.send('presence', dest, replacer === 'Anyone' ? false : replacer, additional);
break;
}
case 'zone-temperature':
case 'weather-temperature': {
if (historyService) historyService.addEntry({ time: moment().unix(), temp: value.newValue, humidity: 0, ppm: 0 });
break;
}
case 'zone-humidity': {
if (historyService) historyService.addEntry({ time: moment().unix(), temp: 0, humidity: value.newValue, ppm: 0 });
break;
}
default:
Logger.warn('Accessory with unknown subtype wanted to store history data', accessory.displayName);
break;
}
}
}
function persistZoneStates(homeId, zoneStates) {
helpers[config.homeId].persistPromise = helpers[config.homeId].persistPromise.then(() => _persistZoneStates(homeId, zoneStates));
}
async function _persistZoneStates(homeId, zoneStates) {
if ((Date.now() - helpers[config.homeId].lastPersistZoneStates) < (10 * 1000)) return;
try {
if (zoneStates && Object.keys(zoneStates).length) {
const homeData = {};
homeData.zoneStates = zoneStates;
await writeFile(join(helpers[config.homeId].storagePath, `tado-states-${homeId}.json`), JSON.stringify(homeData, null, 2), "utf-8");
helpers[config.homeId].lastPersistZoneStates = Date.now();
} else {
Logger.warn(`Skipping persistence of tado states file for home ${homeId}: zone states are empty.`);
}
} catch (error) {
Logger.error(`Error while updating the tado states file for home ${homeId}: ${error.message || error}`);
}
}
async function refreshHistoryServices() {
if (!helpers[config.homeId].refreshHistoryHandlers.length) return;
try {
//wait for fakegato history services to be loaded
await timeout(4000);
for (const refreshHistory of helpers[config.homeId].refreshHistoryHandlers) {
refreshHistory();
}
} catch (error) {
Logger.error(`Error while refreshing history services: ${error.message || error}`);
}
}
function initTasks() {
if (helpers[config.homeId].tasksInitialized) return;
helpers[config.homeId].tasksInitialized = true;
void getStates();
setInterval(() => void getStates(), helpers[config.homeId].statesIntervalTime);
}
async function getStates() {
helpers[config.homeId].lastGetStates = Date.now();
try {
//ME
if (!config.homeId) await updateMe();
//Home
if (!config.temperatureUnit) await updateHome();
//Zones
if (config.zones.length) await updateZones();
//MobileDevices
if (config.presence.length) await updateMobileDevices();
//Weather
if (config.weather.temperatureSensor || config.weather.solarIntensity) await updateWeather();
//RunningTime
if (config.extras.centralSwitch && config.extras.runningInformation) await updateRunningTime();
//Presence Lock
if (config.extras.presenceLock) await updatePresence();
//Child Lock
if (config.childLock.length) await updateDevices();
} catch (error) {
Logger.error(`Failed to get states: ${error.message || error}`);
} finally {
void refreshHistoryServices();
}
}
async function updateMe() {
if (settingStates()) return;
Logger.debug('Polling User Info...', config.homeName);
const me = await tado.getMe();
if (config.homeName !== me.homes[0].name) throw ('Cannot find requested home in the API!', config.homeName);
config.homeId = me.homes[0].id;
}
async function updateHome() {
if (settingStates()) return;
Logger.debug('Polling Home Info...', config.homeName);
const home = await tado.getHome(config.homeId);
if (!config.temperatureUnit) config.temperatureUnit = home.temperatureUnit || 'CELSIUS';
//config.skills = home.skills || []; //do we need this?
if (
!config.geolocation ||
(config.geolocation && !config.geolocation.longitude) ||
!config.geolocation.latitude
) {
if (!home.geolocation) home.geolocation = {};
config.geolocation = {
longitude: (home.geolocation.longitude || '').toString() || false,
latitude: (home.geolocation.latitude || '').toString() || false,
};
}
}
async function updateZones() {
if (helpers[config.homeId].updateZonesRunning) {
helpers[config.homeId].updateZonesNextQueued = true;
return;
}
helpers[config.homeId].updateZonesRunning = true;
try {
while (true) {
try {
await _updateZones();
} catch (error) {
Logger.error(`Failed to update zones: ${error.message || error}`);
}
if (helpers[config.homeId].updateZonesNextQueued) {
helpers[config.homeId].updateZonesNextQueued = false;
//continue with loop
} else {
break;
}
}
} finally {
helpers[config.homeId].updateZonesRunning = false;
}
}
async function _updateZones() {
if (settingStates()) return;
Logger.debug('Polling Zones...', config.homeName);
//CentralSwitch
let inManualMode = 0;
let inOffMode = 0;
let inAutoMode = 0;
let zonesWithoutID = config.zones.filter((zone) => zone && !zone.id);
if (zonesWithoutID.length) {
const allZones = (await tado.getZones(config.homeId)) || [];
for (const [index, zone] of config.zones.entries()) {
allZones.forEach((zoneWithID) => {
if (zoneWithID.name === zone.name) config.zones[index].id = zoneWithID.id;
});
}
}
const allZones = (await tado.getZones(config.homeId)) || [];
for (const [index, zone] of config.zones.entries()) {
allZones.forEach((zoneWithID) => {
if (zoneWithID.name === zone.name) {
const heatAccessory = accessories.filter(
(acc) => acc && acc.displayName === config.homeName + ' ' + zone.name + ' Heater'
);
if (heatAccessory.length) heatAccessory[0].context.config.zoneId = zoneWithID.id;
config.zones[index].id = zoneWithID.id;
config.zones[index].battery = !config.zones[index].noBattery
? zoneWithID.devices.filter(
(device) =>
device &&
(zone.type === 'HEATING' || zone.type === 'AIR_CONDITIONING') &&
typeof device.batteryState === 'string' &&
!device.batteryState.includes('NORMAL')
).length
? zoneWithID.devices.filter((device) => device && !device.batteryState.includes('NORMAL'))[0]
.batteryState
: zoneWithID.devices.filter((device) => device && device.duties.includes('ZONE_LEADER'))[0].batteryState
: false;
config.zones[index].openWindowEnabled =
zoneWithID.openWindowDetection && zoneWithID.openWindowDetection.enabled ? true : false;
}
});
}
let zoneStates = {};
if (config.zones?.length) {
zoneStates = (await tado.getZoneStates(config.homeId))["zoneStates"] ?? {};
void persistZoneStates(config.homeId, zoneStates);
}
for (const zone of config.zones) {
const zoneState = zoneStates[zone.id.toString()];
Logger.debug(`Update state of zone ${zone.id} to:`, zoneState);
let currentState, targetState, currentTemp, targetTemp, humidity, active, battery, tempEqual;
if (zoneState.setting.type === 'HEATING') {
battery = zone.battery === 'NORMAL' ? 100 : 10;
if (zoneState.sensorDataPoints.humidity) humidity = zoneState.sensorDataPoints.humidity.percentage;
//HEATING
if (zoneState.sensorDataPoints.insideTemperature) {
currentTemp =
config.temperatureUnit === 'FAHRENHEIT'
? zoneState.sensorDataPoints.insideTemperature.fahrenheit
: zoneState.sensorDataPoints.insideTemperature.celsius;
if (zoneState.setting.power === 'ON') {
targetTemp =
config.temperatureUnit === 'FAHRENHEIT'
? zoneState.setting.temperature.fahrenheit
: zoneState.setting.temperature.celsius;
tempEqual = Math.round(currentTemp) === Math.round(targetTemp);
//show as currently heating if current temp is lower than target temp, otherwise show as temp set
currentState = currentTemp < targetTemp ? 1 : 0;
//check if auto mode is enabled
targetState = zoneState.overlayType === null ? 3 : 1;
active = 1;
} else {
//heating is switched off
currentState = 0;
targetState = 0;
active = 0;
}
if (targetState === undefined && zoneState.overlayType === null) {
targetState = 3;
}
}
//Thermostat/HeaterCooler
const thermoAccessory = accessories.filter(
(acc) =>
acc &&
(acc.context.config.subtype === 'zone-thermostat' ||
acc.context.config.subtype === 'zone-heatercooler' ||
acc.context.config.subtype === 'zone-heatercooler-ac')
);
if (thermoAccessory.length) {
thermoAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let serviceThermostat = acc.getService(api.hap.Service.Thermostat);
let serviceHeaterCooler = acc.getService(api.hap.Service.HeaterCooler);
let serviceBattery = acc.getService(api.hap.Service.BatteryService);
let characteristicBattery = api.hap.Characteristic.BatteryLevel;
if (serviceBattery && zone.battery) {
serviceBattery.getCharacteristic(characteristicBattery).updateValue(battery);
}
if (serviceThermostat) {
let characteristicCurrentTemp = api.hap.Characteristic.CurrentTemperature;
let characteristicTargetTemp = api.hap.Characteristic.TargetTemperature;
let characteristicCurrentState = api.hap.Characteristic.CurrentHeatingCoolingState;
let characteristicTargetState = api.hap.Characteristic.TargetHeatingCoolingState;
let characteristicHumidity = api.hap.Characteristic.CurrentRelativeHumidity;
let characteristicUnit = api.hap.Characteristic.TemperatureDisplayUnits;
if (!isNaN(currentTemp)) {
acc.context.config.temperatureUnit = acc.context.config.temperatureUnit || config.temperatureUnit;
let isFahrenheit = serviceThermostat.getCharacteristic(characteristicUnit).value === 1;
let unitChanged = config.temperatureUnit !== acc.context.config.temperatureUnit;
let cToF = (c) => Math.round((c * 9) / 5 + 32);
let fToC = (f) => Math.round(((f - 32) * 5) / 9);
let newValue = unitChanged ? (isFahrenheit ? cToF(currentTemp) : fToC(currentTemp)) : currentTemp;
serviceThermostat.getCharacteristic(characteristicCurrentTemp).updateValue(newValue);
}
if (!isNaN(targetTemp))
serviceThermostat.getCharacteristic(characteristicTargetTemp).updateValue(targetTemp);
if (!isNaN(currentState))
serviceThermostat.getCharacteristic(characteristicCurrentState).updateValue(currentState);
if (!isNaN(targetState))
serviceThermostat.getCharacteristic(characteristicTargetState).updateValue(targetState);
if (!isNaN(humidity) && serviceThermostat.testCharacteristic(characteristicHumidity))
serviceThermostat.getCharacteristic(characteristicHumidity).updateValue(humidity);
}
if (serviceHeaterCooler) {
let characteristicHumidity = api.hap.Characteristic.CurrentRelativeHumidity;
let characteristicCurrentTemp = api.hap.Characteristic.CurrentTemperature;
let characteristicTargetTempHeating = api.hap.Characteristic.HeatingThresholdTemperature;
let characteristicTargetTempCooling = api.hap.Characteristic.CoolingThresholdTemperature;
let characteristicCurrentState = api.hap.Characteristic.CurrentHeaterCoolerState;
let characteristicTargetState = api.hap.Characteristic.TargetHeaterCoolerState;
let characteristicActive = api.hap.Characteristic.Active;
currentState = active ? (targetState === 3 || tempEqual ? 1 : currentState + 1) : 0;
targetState = 1;
if (!isNaN(active)) serviceHeaterCooler.getCharacteristic(characteristicActive).updateValue(active);
if (!isNaN(currentTemp))
serviceHeaterCooler.getCharacteristic(characteristicCurrentTemp).updateValue(currentTemp);
if (!isNaN(targetTemp)) {
serviceHeaterCooler.getCharacteristic(characteristicTargetTempHeating).updateValue(targetTemp);
serviceHeaterCooler.getCharacteristic(characteristicTargetTempCooling).updateValue(targetTemp);
}
if (!isNaN(currentState))
serviceHeaterCooler.getCharacteristic(characteristicCurrentState).updateValue(currentState);
if (!isNaN(targetState))
serviceHeaterCooler.getCharacteristic(characteristicTargetState).updateValue(targetState);
if (!isNaN(humidity) && serviceHeaterCooler.testCharacteristic(characteristicHumidity))
serviceHeaterCooler.getCharacteristic(characteristicHumidity).updateValue(humidity);
}
}
});
}
} else {
// Non-HEATING zones (AIR_CONDITIONING, HOT_WATER, etc.)
battery = zone.battery === 'NORMAL' ? 100 : 10;
if (zoneState.sensorDataPoints.humidity) {
humidity = zoneState.sensorDataPoints.humidity.percentage;
}
// Get current temperature from sensor data (same as HEATING zones)
if (zoneState.sensorDataPoints.insideTemperature) {
currentTemp =
config.temperatureUnit === 'FAHRENHEIT'
? zoneState.sensorDataPoints.insideTemperature.fahrenheit
: zoneState.sensorDataPoints.insideTemperature.celsius;
}
if (zoneState.setting.power === 'ON') {
active = 1;
// Get target temperature from setting
targetTemp =
zoneState.setting.temperature !== null && zoneState.setting.temperature
? config.temperatureUnit === 'FAHRENHEIT'
? zoneState.setting.temperature.fahrenheit
: zoneState.setting.temperature.celsius
: undefined;
// Enhanced AC state handling
if (zone.type === 'AIR_CONDITIONING') {
const acMode = zoneState.setting.mode || 'COOL';
tempEqual = currentTemp && targetTemp ? Math.abs(currentTemp - targetTemp) < 0.5 : false;
// Map AC modes to Apple Home states
switch (acMode.toUpperCase()) {
case 'HEAT':
targetState = 1; // Heating
currentState = tempEqual ? 1 : (currentTemp < targetTemp ? 2 : 1); // Idle or Heating
break;
case 'COOL':
case 'AUTO':
default:
targetState = 2; // Cooling
currentState = tempEqual ? 1 : (currentTemp > targetTemp ? 3 : 1); // Idle or Cooling
break;
}
} else {
// Non-AC zones (HOT_WATER, etc.)
currentState = zoneState.overlayType === null ? 1 : 2;
targetState = 1;
}
} else {
active = 0;
currentState = 0;
targetTemp = undefined;
targetState = zone.type === 'AIR_CONDITIONING' ? 2 : 1; // Default to cooling for AC, heating for others
}
//Thermostat/HeaterCooler
const heaterAccessory = accessories.filter(
(acc) => acc && (acc.context.config.subtype === 'zone-heatercooler-boiler' ||
acc.context.config.subtype === 'zone-heatercooler-ac')
);
const switchAccessory = accessories.filter((acc) => acc && acc.context.config.subtype === 'zone-switch');
const faucetAccessory = accessories.filter((acc) => acc && acc.context.config.subtype === 'zone-faucet');
if (heaterAccessory.length) {
heaterAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let service = acc.getService(api.hap.Service.HeaterCooler);
let characteristicCurrentTemp = api.hap.Characteristic.CurrentTemperature;
let characteristicActive = api.hap.Characteristic.Active;
let characteristicCurrentState = api.hap.Characteristic.CurrentHeaterCoolerState;
let characteristicTargetState = api.hap.Characteristic.TargetHeaterCoolerState;
let characteristicTargetTempHeating = api.hap.Characteristic.HeatingThresholdTemperature;
let characteristicTargetTempCooling = api.hap.Characteristic.CoolingThresholdTemperature;
service.getCharacteristic(characteristicActive).updateValue(active);
service.getCharacteristic(characteristicCurrentState).updateValue(currentState);
service.getCharacteristic(characteristicTargetState).updateValue(targetState);
// Set current temperature from sensor data
if (!isNaN(currentTemp) || acc.context.currentTemp) {
if (!isNaN(currentTemp)) acc.context.currentTemp = currentTemp; //store current temp in config
service.getCharacteristic(characteristicCurrentTemp).updateValue(acc.context.currentTemp);
}
// Set target temperature for both heating and cooling
if (!isNaN(targetTemp)) {
// For AC zones, set temperature based on the mode
if (zone.type === 'AIR_CONDITIONING') {
// Always set both characteristics but log which one is active
service.getCharacteristic(characteristicTargetTempHeating).updateValue(targetTemp);
service.getCharacteristic(characteristicTargetTempCooling).updateValue(targetTemp);
} else {
// Non-AC zones (like boiler/hot water)
service.getCharacteristic(characteristicTargetTempHeating).updateValue(targetTemp);
service.getCharacteristic(characteristicTargetTempCooling).updateValue(targetTemp);
}
}
// Fan speed polling removed for AIR_CONDITIONING zones
// Update humidity for all zones that support it
if (!isNaN(humidity) && service.testCharacteristic(api.hap.Characteristic.CurrentRelativeHumidity)) {
service.getCharacteristic(api.hap.Characteristic.CurrentRelativeHumidity).updateValue(humidity);
}
}
});
}
if (switchAccessory.length) {
switchAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let service = acc.getService(api.hap.Service.Switch);
let characteristic = api.hap.Characteristic.On;
service.getCharacteristic(characteristic).updateValue(active ? true : false);
}
});
}
if (faucetAccessory.length) {
faucetAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let service = acc.getService(api.hap.Service.Valve);
let characteristicActive = api.hap.Characteristic.Active;
let characteristicInUse = api.hap.Characteristic.InUse;
service.getCharacteristic(characteristicActive).updateValue(active ? 1 : 0);
service.getCharacteristic(characteristicInUse).updateValue(active ? 1 : 0);
}
});
}
}
//TemperatureSensor
const tempAccessory = accessories.filter((acc) => acc && acc.context.config.subtype === 'zone-temperature');
if (tempAccessory.length) {
tempAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let serviceBattery = acc.getService(api.hap.Service.BatteryService);
let characteristicBattery = api.hap.Characteristic.BatteryLevel;
if (serviceBattery && !isNaN(battery)) {
serviceBattery.getCharacteristic(characteristicBattery).updateValue(battery);
}
if (!isNaN(currentTemp)) {
let service = acc.getService(api.hap.Service.TemperatureSensor);
let characteristic = api.hap.Characteristic.CurrentTemperature;
service.getCharacteristic(characteristic).updateValue(currentTemp);
}
}
});
}
//HumiditySensor
const humidityAccessory = accessories.filter((acc) => acc && acc.context.config.subtype === 'zone-humidity');
humidityAccessory.forEach((acc) => {
if (acc.displayName.includes(zone.name)) {
let serviceBattery = acc.getService(api.hap.Service.BatteryService);
let characteristicBattery = api.hap.Characteristic.BatteryLevel;
if (serviceBattery && !isNaN(battery)) {
serviceBattery.getCharacteristic(characteristicBattery).updateValue