UNPKG

@homebridge-plugins/homebridge-tado

Version:
1,237 lines (1,005 loc) 62.3 kB
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