UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

1,126 lines (1,007 loc) 113 kB
// Nest Thermostat - HomeKit integration // Part of homebridge-nest-accfactory // // HomeKit accessory implementation for Nest Thermostat devices. // Provides thermostat control, fan control, // optional humidity services, and Eve Home integration using // translated Nest and Google API data. // // Responsibilities: // - Expose thermostat control via HomeKit Thermostat service // - Synchronise HVAC mode, target temperatures, humidity, and device status // - Manage optional fan, humidity sensor, battery, and humidifier/dehumidifier services // - Support linked remote temperature sensors and active sensor reporting // - Record thermostat activity and schedule state for Eve Home integration // - Optionally integrate external automation modules for climate control actions // // Services: // - Thermostat (primary service) // - Fanv2 (optional, when fan support is available) // - Battery (hidden, for battery-powered models) // - HumiditySensor (optional, when separately exposed) // - HumidifierDehumidifier (optional, when supported) // // Features: // - Heat, cool, auto/range, and off modes // - Dynamic mode validation based on thermostat capabilities // - Target temperature and threshold control with 0.5° increments // - Fan control with optional variable speed and duration-based runtime // - Humidifier and dehumidifier support when available // - Battery monitoring and filter replacement reporting // - Linked remote temperature sensor support // - Eve Home history and schedule payload integration // - Optional external module hooks for custom HVAC automation // // Notes: // - Supports both Nest and Google APIs, with Google data preferred when available // - Eco modes are normalised to standard HomeKit-compatible mode presentation // - Some advanced thermostat behaviour is exposed through Eve Home custom payloads // - External module support is intended for advanced custom integrations // // Data Translation: // - Raw thermostat data is mapped using THERMOSTAT_FIELD_MAP // - processRawData() builds device objects and applies configuration overrides // - Nest and Google sources are normalised into a shared HomeKit-facing model // // Mark Hulskamp 'use strict'; // Define nodejs module requirements import path from 'node:path'; import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; // Define our modules import HomeKitDevice from '../HomeKitDevice.js'; import { processSoftwareVersion, scaleValue, adjustTemperature, parseDurationToSeconds } from '../utils.js'; import { buildMappedObject, createMappingContext } from '../translator.js'; // Define constants import { DATA_SOURCE, THERMOSTAT_MIN_TEMPERATURE, THERMOSTAT_MAX_TEMPERATURE, LOW_BATTERY_LEVEL, PROTOBUF_RESOURCES, DAYS_OF_WEEK_FULL, DAYS_OF_WEEK_SHORT, __dirname, DEVICE_TYPE, FAN_DURATION_TIMES, } from '../consts.js'; export default class NestThermostat extends HomeKitDevice { static TYPE = DEVICE_TYPE.THERMOSTAT; static VERSION = '2026.05.05'; // Code version thermostatService = undefined; batteryService = undefined; humidityService = undefined; fanService = undefined; // Fan control humidifierDehumidifierService = undefined; // humidifier & dehumidifier control #external = {}; // External module functions // Class functions async onAdd() { // Pre-0.4.0 cleanup // Remove legacy occupancy sensor from Thermostat accessory this.accessory.services .filter((service) => service.UUID === this.hap.Service.OccupancySensor.UUID) .forEach((service) => { this.accessory.removeService(service); }); // End of pre-0.4.0 cleanup // Setup the thermostat service if not already present on the accessory, and link it to the Eve app if configured to do so this.thermostatService = this.addHKService(this.hap.Service.Thermostat, '', 1, { messages: this.message.bind(this) }); this.thermostatService.setPrimaryService(); // Fix for coding error with versions below 2025.08.20 where fan specific characteristics were added directly to the thermostat service // Thanks to @gizmotronic for raising this issue this.thermostatService.removeCharacteristic(this.hap.Characteristic.RotationSpeed); this.thermostatService.removeCharacteristic(this.hap.Characteristic.Active); // Setup set characteristics // Patch to avoid characteristic errors when setting initial property ranges this.hap.Characteristic.TargetTemperature.prototype.getDefaultValue = () => { return THERMOSTAT_MIN_TEMPERATURE; // start at minimum target temperature }; this.hap.Characteristic.HeatingThresholdTemperature.prototype.getDefaultValue = () => { return THERMOSTAT_MIN_TEMPERATURE; // start at minimum heating threshold }; this.hap.Characteristic.CoolingThresholdTemperature.prototype.getDefaultValue = () => { return THERMOSTAT_MAX_TEMPERATURE; // start at maximum cooling threshold }; if (this.deviceData?.has_humidifier === true || this.deviceData?.has_dehumidifier === true) { // Set default value for TargetHumidifierDehumidifierState based on capabilities this.hap.Characteristic.TargetHumidifierDehumidifierState.prototype.getDefaultValue = () => { if (this.deviceData?.has_humidifier === true && this.deviceData?.has_dehumidifier === true) { return this.hap.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER_OR_DEHUMIDIFIER; } else if (this.deviceData?.has_humidifier === true) { return this.hap.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER; } else { return this.hap.Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER; } }; } // Used to indicate active temperature if the thermostat is using its temperature sensor data // or an external temperature sensor ie: Nest Temperature Sensor this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.StatusActive); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.StatusFault); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.LockPhysicalControls, { onSet: (value) => { this.setChildlock('', value); }, onGet: () => { return this.deviceData.temperature_lock === true ? this.hap.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : this.hap.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED; }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.CurrentRelativeHumidity, { onGet: () => { return Number.isFinite(Number(this.deviceData.current_humidity)) === true ? Number(this.deviceData.current_humidity) : null; }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TemperatureDisplayUnits, { onSet: (value) => { this.setDisplayUnit(value); }, onGet: () => { return this.deviceData.temperature_scale.toUpperCase() === 'C' ? this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS : this.hap.Characteristic.TemperatureDisplayUnits.FAHRENHEIT; }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.CurrentTemperature, { props: { minStep: 0.5 }, onGet: () => { return Number.isFinite(Number(this.deviceData.current_temperature)) === true ? Number(this.deviceData.current_temperature) : null; }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TargetTemperature, { props: { minStep: 0.5, minValue: THERMOSTAT_MIN_TEMPERATURE, maxValue: THERMOSTAT_MAX_TEMPERATURE, }, onSet: (value) => { this.setTemperature(this.hap.Characteristic.TargetTemperature, value); }, onGet: () => { return this.getTemperature(this.hap.Characteristic.TargetTemperature); }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.CoolingThresholdTemperature, { props: { minStep: 0.5, minValue: THERMOSTAT_MIN_TEMPERATURE, maxValue: THERMOSTAT_MAX_TEMPERATURE, }, onSet: (value) => { this.setTemperature(this.hap.Characteristic.CoolingThresholdTemperature, value); }, onGet: () => { return this.getTemperature(this.hap.Characteristic.CoolingThresholdTemperature); }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.HeatingThresholdTemperature, { props: { minStep: 0.5, minValue: THERMOSTAT_MIN_TEMPERATURE, maxValue: THERMOSTAT_MAX_TEMPERATURE, }, onSet: (value) => { this.setTemperature(this.hap.Characteristic.HeatingThresholdTemperature, value); }, onGet: () => { return this.getTemperature(this.hap.Characteristic.HeatingThresholdTemperature); }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TargetHeatingCoolingState, { props: { validValues: this.deviceData?.can_cool === true && this.deviceData?.can_heat === true ? [ this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.HEAT, this.hap.Characteristic.TargetHeatingCoolingState.COOL, this.hap.Characteristic.TargetHeatingCoolingState.AUTO, ] : this.deviceData?.can_heat === true ? [this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.HEAT] : this.deviceData?.can_cool === true ? [this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.COOL] : [this.hap.Characteristic.TargetHeatingCoolingState.OFF], }, onSet: (value) => { this.setMode(value); }, onGet: () => { return this.getMode(); }, }); if (this.deviceData?.has_air_filter === true) { // We have the capability for an air filter, so setup filter change characterisitc this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.FilterChangeIndication); } if (this.deviceData?.has_air_filter === false) { // No longer configured to have an air filter, so remove characteristic from the accessory this.thermostatService.removeCharacteristic(this.hap.Characteristic.FilterChangeIndication); } // Setup battery service if not already present on the accessory this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1); this.batteryService.setHiddenService(true); this.thermostatService.addLinkedService(this.batteryService); // Setup fan service if supported by the thermostat and not already present on the accessory if (this.deviceData?.has_fan === true) { this.#setupFan(); } if (this.deviceData?.has_fan === false) { // No longer have a Fan configured and service present, so removed it this.fanService = this.accessory.getService(this.hap.Service.Fanv2); if (this.fanService !== undefined) { this.accessory.removeService(this.fanService); } this.fanService = undefined; } // Setup humidifier & dehumidifier service if supported by the thermostat and not already present on the accessory if (this.deviceData?.has_humidifier === true || this.deviceData?.has_dehumidifier === true) { this.#setupHumidifierDehumidifier(this.deviceData?.has_humidifier, this.deviceData?.has_dehumidifier); } if (this.deviceData?.has_humidifier === false && this.deviceData?.has_dehumidifier === false) { // No longer have a dehumidifier or humidifier configured and service present, so removed it this.humidifierDehumidifierService = this.accessory.getService(this.hap.Service.HumidifierDehumidifier); if (this.humidifierDehumidifierService !== undefined) { this.accessory.removeService(this.humidifierDehumidifierService); } this.humidifierDehumidifierService = undefined; } // Setup humidity service if configured to be separate and not already present on the accessory if (this.deviceData?.humiditySensor === true) { this.humidityService = this.addHKService(this.hap.Service.HumiditySensor, '', 1); this.thermostatService.addLinkedService(this.humidityService); this.addHKCharacteristic(this.humidityService, this.hap.Characteristic.CurrentRelativeHumidity, { onGet: () => { return Number.isFinite(Number(this.deviceData.current_humidity)) === true ? Number(this.deviceData.current_humidity) : null; }, }); } if (this.deviceData?.humiditySensor === false) { // No longer have a separate humidity sensor configure and service present, so removed it this.humidityService = this.accessory.getService(this.hap.Service.HumiditySensor); if (this.humidityService !== undefined) { this.accessory.removeService(this.humidityService); } this.humidityService = undefined; } // Load external module if configured // Supports flexible functions: cool, heat, fan, dehumidifier, humidifier, off // This is undocumented as it's for my specific use case this.#external = await this.#loadExternalModule(this.deviceData?.external, [ 'cool', 'heat', 'fan', 'dehumidifier', 'humidifier', 'off', ]); if (this.#external !== undefined && Object.keys(this.#external).length > 0) { this.postSetupDetail('Using external module with modes ' + Object.keys(this.#external).join(', ')); } else if (this.#external !== undefined) { // Module loaded but provided no valid functions this?.log?.warn?.('External module configured but provides no recognised functions'); } // Extra setup details for output this.humidityService !== undefined && this.postSetupDetail('Separate humidity sensor'); } onRemove() { this.accessory.removeService(this.thermostatService); this.accessory.removeService(this.batteryService); this.accessory.removeService(this.humidityService); this.accessory.removeService(this.fanService); this.accessory.removeService(this.humidifierDehumidifierService); this.thermostatService = undefined; this.batteryService = undefined; this.humidityService = undefined; this.fanService = undefined; this.humidifierDehumidifierService = undefined; this.#external = {}; } onUpdate(deviceData) { if ( typeof deviceData !== 'object' || deviceData?.constructor !== Object || this.thermostatService === undefined || this.batteryService === undefined ) { return; } let historyEntry = {}; let temperatureScale = deviceData.temperature_scale?.toUpperCase?.() === 'F' ? 'F' : 'C'; let hvacMode = typeof deviceData.hvac_mode === 'string' ? deviceData.hvac_mode.toUpperCase() : 'OFF'; let previousHvacMode = typeof this.deviceData?.hvac_mode === 'string' ? this.deviceData.hvac_mode.toUpperCase() : 'OFF'; let hvacState = typeof deviceData.hvac_state === 'string' ? deviceData.hvac_state.toUpperCase() : 'OFF'; let previousHvacState = typeof this.deviceData?.hvac_state === 'string' ? this.deviceData.hvac_state.toUpperCase() : 'OFF'; this.thermostatService.updateCharacteristic( this.hap.Characteristic.TemperatureDisplayUnits, temperatureScale === 'C' ? this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS : this.hap.Characteristic.TemperatureDisplayUnits.FAHRENHEIT, ); if (deviceData.current_temperature !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.CurrentTemperature, deviceData.current_temperature); } // If thermostat isn't online or removed from base, report in HomeKit this.thermostatService.updateCharacteristic( this.hap.Characteristic.StatusFault, deviceData.online === true && deviceData.removed_from_base === false ? this.hap.Characteristic.StatusFault.NO_FAULT : this.hap.Characteristic.StatusFault.GENERAL_FAULT, ); this.thermostatService.updateCharacteristic( this.hap.Characteristic.LockPhysicalControls, deviceData.temperature_lock === true ? this.hap.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : this.hap.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED, ); // Update air filter status if has been added if (this.thermostatService.testCharacteristic(this.hap.Characteristic.FilterChangeIndication) === true) { this.thermostatService.updateCharacteristic( this.hap.Characteristic.FilterChangeIndication, deviceData.has_air_filter === true && deviceData.filter_replacement_needed === true ? this.hap.Characteristic.FilterChangeIndication.CHANGE_FILTER : this.hap.Characteristic.FilterChangeIndication.FILTER_OK, ); } // Using a temperature sensor as active temperature? // Probably not the best way for HomeKit, but works ;-) // Maybe a custom characteristic would be better? this.thermostatService.updateCharacteristic(this.hap.Characteristic.StatusActive, (deviceData.active_rcs_sensor ?? '') === ''); // Update battery status if (deviceData.battery_level !== undefined) { this.batteryService.updateCharacteristic(this.hap.Characteristic.BatteryLevel, deviceData.battery_level); this.batteryService.updateCharacteristic( this.hap.Characteristic.StatusLowBattery, deviceData.battery_level > LOW_BATTERY_LEVEL ? this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW, ); this.batteryService.updateCharacteristic( this.hap.Characteristic.ChargingState, (deviceData.battery_level > this.deviceData.battery_level && this.deviceData.battery_level !== 0 ? true : false) ? this.hap.Characteristic.ChargingState.CHARGING : this.hap.Characteristic.ChargingState.NOT_CHARGING, ); } // Update separate humidity sensor if configured to do so if (this.humidityService !== undefined && deviceData.current_humidity !== undefined) { this.humidityService.updateCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity, deviceData.current_humidity); } // Update humidity on thermostat if (deviceData.current_humidity !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity, deviceData.current_humidity); } // Check for fan setup change on thermostat if (deviceData.has_fan !== this.deviceData.has_fan) { if (deviceData.has_fan === true && this.deviceData.has_fan === false && this.fanService === undefined) { // Fan has been added this.#setupFan(); } if (deviceData.has_fan === false && this.deviceData.has_fan === true && this.fanService !== undefined) { // Fan has been removed this.accessory.removeService(this.fanService); this.fanService = undefined; } this?.log?.info?.( 'Fan setup on thermostat "%s" has changed. Fan was %s', deviceData.description, this.fanService === undefined ? 'removed' : 'added', ); } // Check for humidifier/dehumidifier setup change on thermostat if (deviceData.has_humidifier !== this.deviceData.has_humidifier || deviceData.has_dehumidifier !== this.deviceData.has_dehumidifier) { if (this.humidifierDehumidifierService === undefined) { // Service doesn't exist yet, create it if we have at least one capability if (deviceData.has_humidifier === true || deviceData.has_dehumidifier === true) { this.#setupHumidifierDehumidifier(deviceData.has_humidifier, deviceData.has_dehumidifier); } } else { // Service exists, either adjust props or remove it if (deviceData.has_humidifier === true || deviceData.has_dehumidifier === true) { // Adjust the validValues based on updated capabilities this.humidifierDehumidifierService.getCharacteristic(this.hap.Characteristic.TargetHumidifierDehumidifierState).setProps({ validValues: [ deviceData.has_humidifier === true && this.hap.Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER, deviceData.has_dehumidifier === true && this.hap.Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER, ].filter(Boolean), }); } else { // Both capabilities removed, so remove the service this.accessory.removeService(this.humidifierDehumidifierService); this.humidifierDehumidifierService = undefined; } } this?.log?.info?.( 'Humidity control setup on thermostat "%s" has changed: %s', deviceData.description, [ deviceData.has_humidifier !== this.deviceData.has_humidifier && 'Humidifier ' + (deviceData.has_humidifier === true ? 'added' : 'removed'), deviceData.has_dehumidifier !== this.deviceData.has_dehumidifier && 'Dehumidifier ' + (deviceData.has_dehumidifier === true ? 'added' : 'removed'), ] .filter(Boolean) .join(', '), ); } if (deviceData.can_cool !== this.deviceData.can_cool || deviceData.can_heat !== this.deviceData.can_heat) { // Heating and/cooling setup has changed on thermostat // Limit prop ranges if (deviceData.can_cool === false && deviceData.can_heat === true) { // Can heat only, so set values allowed for mode off/heat this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).setProps({ validValues: [this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.HEAT], }); } if (deviceData.can_cool === true && deviceData.can_heat === false) { // Can cool only this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).setProps({ validValues: [this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.COOL], }); } if (deviceData.can_cool === true && deviceData.can_heat === true) { // heat and cool this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).setProps({ validValues: [ this.hap.Characteristic.TargetHeatingCoolingState.OFF, this.hap.Characteristic.TargetHeatingCoolingState.HEAT, this.hap.Characteristic.TargetHeatingCoolingState.COOL, this.hap.Characteristic.TargetHeatingCoolingState.AUTO, ], }); } if (deviceData.can_cool === false && deviceData.can_heat === false) { // only off mode this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).setProps({ validValues: [this.hap.Characteristic.TargetHeatingCoolingState.OFF], }); } this?.log?.info?.('Heating/cooling setup on thermostat on "%s" has changed', deviceData.description); } // Update current mode, temperatures and log any changes if (deviceData.target_temperature_low !== undefined && deviceData.target_temperature_low !== this.deviceData.target_temperature_low) { this.#logTemperatureChange( 'Thermostat', 'heating', deviceData.target_temperature_low, hvacMode.includes('ECO') === true, temperatureScale, ); } if ( deviceData.target_temperature_high !== undefined && deviceData.target_temperature_high !== this.deviceData.target_temperature_high ) { this.#logTemperatureChange( 'Thermostat', 'cooling', deviceData.target_temperature_high, hvacMode.includes('ECO') === true, temperatureScale, ); } if (hvacMode !== previousHvacMode) { this.#logModeChange('Thermostat', deviceData.hvac_mode); } if (deviceData.can_heat === true && (hvacMode === 'HEAT' || hvacMode === 'ECOHEAT')) { // heating mode, either eco or normal if (deviceData.target_temperature_low !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, deviceData.target_temperature_low); historyEntry.target = { low: 0, high: deviceData.target_temperature_low }; } this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.HEAT, ); } if (deviceData.can_cool === true && (hvacMode === 'COOL' || hvacMode === 'ECOCOOL')) { // cooling mode, either eco or normal if (deviceData.target_temperature_high !== undefined) { this.thermostatService.updateCharacteristic( this.hap.Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high, ); this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, deviceData.target_temperature_high); historyEntry.target = { low: deviceData.target_temperature_high, high: 0 }; } this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.COOL, ); } if (deviceData.can_cool === true && deviceData.can_heat === true && (hvacMode === 'RANGE' || hvacMode === 'ECORANGE')) { // range mode, either eco or normal if (deviceData.target_temperature_low !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); } if (deviceData.target_temperature_high !== undefined) { this.thermostatService.updateCharacteristic( this.hap.Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high, ); } if (deviceData.target_temperature !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, deviceData.target_temperature); } this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.AUTO, ); historyEntry.target = { low: deviceData.target_temperature_low ?? 0, high: deviceData.target_temperature_high ?? 0 }; } if (deviceData.can_cool === false && deviceData.can_heat === false && hvacMode === 'OFF') { // off mode if (deviceData.target_temperature !== undefined) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, deviceData.target_temperature); } this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.OFF, ); historyEntry.target = { low: 0, high: 0 }; } // Update current state if (hvacState === 'HEATING') { if (previousHvacState === 'COOLING' && typeof this.#external?.off === 'function') { this.#external.off(); } if ( (previousHvacState !== 'HEATING' || deviceData.target_temperature_low !== this.deviceData.target_temperature_low) && typeof this.#external?.heat === 'function' && deviceData.target_temperature_low !== undefined ) { this.#external.heat(deviceData.target_temperature_low); } this.thermostatService.updateCharacteristic( this.hap.Characteristic.CurrentHeatingCoolingState, this.hap.Characteristic.CurrentHeatingCoolingState.HEAT, ); historyEntry.status = 2; } if (hvacState === 'COOLING') { if (previousHvacState === 'HEATING' && typeof this.#external?.off === 'function') { this.#external.off(); } if ( (previousHvacState !== 'COOLING' || deviceData.target_temperature_high !== this.deviceData.target_temperature_high) && typeof this.#external?.cool === 'function' && deviceData.target_temperature_high !== undefined ) { this.#external.cool(deviceData.target_temperature_high); } this.thermostatService.updateCharacteristic( this.hap.Characteristic.CurrentHeatingCoolingState, this.hap.Characteristic.CurrentHeatingCoolingState.COOL, ); historyEntry.status = 1; } if (hvacState === 'OFF') { if (previousHvacState === 'COOLING' && typeof this.#external?.off === 'function') { this.#external.off(); } if (previousHvacState === 'HEATING' && typeof this.#external?.off === 'function') { this.#external.off(); } this.thermostatService.updateCharacteristic( this.hap.Characteristic.CurrentHeatingCoolingState, this.hap.Characteristic.CurrentHeatingCoolingState.OFF, ); historyEntry.status = 0; } if (this.fanService !== undefined) { // fan status on or off if (this.deviceData.fan_state === false && deviceData.fan_state === true && typeof this.#external?.fan === 'function') { this.#external.fan(0); } if (this.deviceData.fan_state === true && deviceData.fan_state === false && typeof this.#external?.off === 'function') { this.#external.off(); } if (deviceData.fan_max_speed > 1 && deviceData.fan_timer_speed !== undefined) { this.fanService.updateCharacteristic( this.hap.Characteristic.RotationSpeed, deviceData.fan_state === true ? (deviceData.fan_timer_speed / deviceData.fan_max_speed) * 100 : 0, ); } this.fanService.updateCharacteristic( this.hap.Characteristic.Active, deviceData.fan_state === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE, ); this.history(this.fanService, { status: deviceData.fan_state === true ? 1 : 0, temperature: deviceData.current_temperature, humidity: deviceData.current_humidity, }); } if (this.humidifierDehumidifierService !== undefined) { // humidifier & dehumidifier status on or off if ( this.deviceData.humidifier_state === false && deviceData.humidifier_state === true && typeof this.#external?.humidifier === 'function' ) { this.#external.humidifier(0); } if (this.deviceData.humidifier_state === true && deviceData.humidifier_state === false && typeof this.#external?.off === 'function') { this.#external.off(); } if ( this.deviceData.dehumidifier_state === false && deviceData.dehumidifier_state === true && typeof this.#external?.dehumidifier === 'function' ) { this.#external.dehumidifier(0); } if ( this.deviceData.dehumidifier_state === true && deviceData.dehumidifier_state === false && typeof this.#external?.off === 'function' ) { this.#external.off(); } this.humidifierDehumidifierService.updateCharacteristic( this.hap.Characteristic.Active, deviceData.humidifier_state === true || deviceData.dehumidifier_state === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE, ); this.humidifierDehumidifierService.updateCharacteristic( this.hap.Characteristic.CurrentHumidifierDehumidifierState, deviceData.humidifier_state === true ? this.hap.Characteristic.CurrentHumidifierDehumidifierState.HUMIDIFYING : deviceData.dehumidifier_state === true ? this.hap.Characteristic.CurrentHumidifierDehumidifierState.DEHUMIDIFYING : this.hap.Characteristic.CurrentHumidifierDehumidifierState.INACTIVE, ); // Update humidity characteristics if available if ( this.humidifierDehumidifierService.testCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity) === true && deviceData.current_humidity !== undefined ) { this.humidifierDehumidifierService.updateCharacteristic( this.hap.Characteristic.CurrentRelativeHumidity, deviceData.current_humidity, ); } if ( this.humidifierDehumidifierService.testCharacteristic(this.hap.Characteristic.RelativeHumidityHumidifierThreshold) === true && deviceData.target_humidity_humidifier !== undefined ) { this.humidifierDehumidifierService.updateCharacteristic( this.hap.Characteristic.RelativeHumidityHumidifierThreshold, deviceData.target_humidity_humidifier, ); } if ( this.humidifierDehumidifierService.testCharacteristic(this.hap.Characteristic.RelativeHumidityDehumidifierThreshold) === true && deviceData.target_humidity_dehumidifier !== undefined ) { this.humidifierDehumidifierService.updateCharacteristic( this.hap.Characteristic.RelativeHumidityDehumidifierThreshold, deviceData.target_humidity_dehumidifier, ); } this.history(this.humidifierDehumidifierService, { status: deviceData.humidifier_state === true ? 1 : deviceData.dehumidifier_state === true ? 2 : 0, temperature: deviceData.current_temperature, humidity: deviceData.current_humidity, }); } // Log thermostat metrics to history only if changed to previous recording this.history(this.thermostatService, { status: historyEntry.status, temperature: deviceData.current_temperature, target: historyEntry.target, humidity: deviceData.current_humidity, }); // Update our internal data with properties Eve will need to process then Notify Eve App of device status changes if linked this.deviceData.online = deviceData.online; this.deviceData.removed_from_base = deviceData.removed_from_base; this.deviceData.vacation_mode = deviceData.vacation_mode; this.deviceData.hvac_mode = deviceData.hvac_mode; this.deviceData.schedules = deviceData.schedules; this.deviceData.schedule_mode = deviceData.schedule_mode; this.historyService?.updateEveHome?.(this.thermostatService); } onMessage(type, message) { if ( typeof type !== 'string' || type === '' || (message !== undefined && (message === null || typeof message !== 'object' || message.constructor !== Object)) ) { return; } if (type === HomeKitDevice?.EVEHOME?.GET) { // Extend Eve Thermo GET payload with device state message.enableschedule = this.deviceData.schedule_mode === 'heat'; message.attached = this.deviceData.online === true && this.deviceData.removed_from_base === false; message.vacation = this.deviceData.vacation_mode === true; message.programs = []; if (['HEAT', 'RANGE'].includes(this.deviceData.schedule_mode?.toUpperCase?.()) === true) { Object.entries(this.deviceData.schedules || {}).forEach(([day, entries]) => { let tempSchedule = []; let tempTemperatures = []; Object.values(entries || {}).forEach((schedule) => { if (schedule.entry_type === 'setpoint' && ['HEAT', 'RANGE'].includes(schedule.type)) { let temp = typeof schedule['temp-min'] === 'number' ? schedule['temp-min'] : schedule.temp; tempSchedule.push({ start: schedule.time, duration: 0, temperature: temp }); tempTemperatures.push(temp); } }); tempSchedule.sort((a, b) => a.start - b.start); let ecoTemp = tempTemperatures.length === 0 ? 0 : Math.min(...tempTemperatures); let comfortTemp = tempTemperatures.length === 0 ? 0 : Math.max(...tempTemperatures); let program = { id: parseInt(day), days: Number.isFinite(Number(day)) === true && DAYS_OF_WEEK_SHORT?.[day] !== undefined ? DAYS_OF_WEEK_SHORT[day].toLowerCase() : 'mon', schedule: [], }; let lastTime = 86400; [...tempSchedule].reverse().forEach((entry) => { if (entry.temperature === comfortTemp) { program.schedule.push({ start: entry.start, duration: lastTime - entry.start, ecotemp: ecoTemp, comforttemp: comfortTemp, }); } lastTime = entry.start; }); message.programs.push(program); }); } return message; } if (type === HomeKitDevice?.EVEHOME?.SET) { if (typeof message?.vacation?.status === 'boolean') { this.set({ uuid: this.deviceData.nest_google_device_uuid, vacation_mode: message.vacation.status }); } if (typeof message?.programs === 'object') { // Future: convert to Nest format and apply via .set() // this.set({ uuid: ..., days: { ... } }); } } } async setFan(fanState, speed) { let currentState = this.fanService.getCharacteristic(this.hap.Characteristic.Active).value; // If we have a rotation speed characteristic, use that get the current fan speed, otherwise we use the current fan state to determine let currentSpeed = this.fanService.testCharacteristic(this.hap.Characteristic.RotationSpeed) === true ? this.fanService.getCharacteristic(this.hap.Characteristic.RotationSpeed).value : currentState === this.hap.Characteristic.Active.ACTIVE ? 100 : 0; if (fanState !== currentState || speed !== currentSpeed) { let isActive = fanState === this.hap.Characteristic.Active.ACTIVE; let scaledSpeed = Math.round((speed / 100) * this.deviceData.fan_max_speed); this.fanService.updateCharacteristic(this.hap.Characteristic.Active, fanState); await this.set({ uuid: this.deviceData.nest_google_device_uuid, fan_state: isActive, fan_duration: this.deviceData.fan_duration, fan_timer_speed: scaledSpeed, }); if (this.fanService.testCharacteristic(this.hap.Characteristic.RotationSpeed) === true) { this.fanService.updateCharacteristic(this.hap.Characteristic.RotationSpeed, speed); } this?.log?.info?.( 'Set fan on thermostat "%s" to "%s"', this.deviceData.description, isActive ? 'On with fan speed of ' + speed + '%' + (this.deviceData.fan_duration > 0 ? ' for ' + (Math.floor(this.deviceData.fan_duration / 604800) > 0 ? Math.floor(this.deviceData.fan_duration / 604800) + ' wk' + (Math.floor(this.deviceData.fan_duration / 604800) > 1 ? 's ' : ' ') : '') + (Math.floor((this.deviceData.fan_duration % 604800) / 86400) > 0 ? Math.floor((this.deviceData.fan_duration % 604800) / 86400) + ' day' + (Math.floor((this.deviceData.fan_duration % 604800) / 86400) > 1 ? 's ' : ' ') : '') + (Math.floor((this.deviceData.fan_duration % 86400) / 3600) > 0 ? Math.floor((this.deviceData.fan_duration % 86400) / 3600) + ' hr' + (Math.floor((this.deviceData.fan_duration % 86400) / 3600) > 1 ? 's ' : ' ') : '') + (Math.floor((this.deviceData.fan_duration % 3600) / 60) > 0 ? Math.floor((this.deviceData.fan_duration % 3600) / 60) + ' min' + (Math.floor((this.deviceData.fan_duration % 3600) / 60) > 1 ? 's' : '') : '') : '') : 'Off', ); } } async setDisplayUnit(temperatureUnit) { let unit = temperatureUnit === this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS ? 'C' : 'F'; this.thermostatService.updateCharacteristic(this.hap.Characteristic.TemperatureDisplayUnits, temperatureUnit); await this.set({ uuid: this.deviceData.nest_google_device_uuid, temperature_scale: unit, }); this?.log?.info?.('Set temperature units on thermostat "%s" to "%s"', this.deviceData.description, unit === 'C' ? '°C' : '°F'); } async setMode(thermostatMode) { if (thermostatMode !== this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).value) { // Work out based on the HomeKit requested mode, what can the thermostat really switch too // We may over-ride the requested HomeKit mode if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.HEAT && this.deviceData.can_heat === false) { thermostatMode = this.hap.Characteristic.TargetHeatingCoolingState.OFF; } if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.COOL && this.deviceData.can_cool === false) { thermostatMode = this.hap.Characteristic.TargetHeatingCoolingState.OFF; } if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.AUTO) { // Workaround for 'Hey Siri, turn on my thermostat' // Appears to automatically request mode as 'auto', but we need to see what Nest device supports if (this.deviceData.can_cool === true && this.deviceData.can_heat === false) { thermostatMode = this.hap.Characteristic.TargetHeatingCoolingState.COOL; } if (this.deviceData.can_cool === false && this.deviceData.can_heat === true) { thermostatMode = this.hap.Characteristic.TargetHeatingCoolingState.HEAT; } if (this.deviceData.can_cool === false && this.deviceData.can_heat === false) { thermostatMode = this.hap.Characteristic.TargetHeatingCoolingState.OFF; } } let mode = ''; if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.OFF) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, this.deviceData.target_temperature); this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.OFF, ); mode = 'off'; } if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.COOL) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, this.deviceData.target_temperature_high); this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.COOL, ); mode = 'cool'; } if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.HEAT) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, this.deviceData.target_temperature_low); this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.HEAT, ); mode = 'heat'; } if (thermostatMode === this.hap.Characteristic.TargetHeatingCoolingState.AUTO) { this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetTemperature, (this.deviceData.target_temperature_low + this.deviceData.target_temperature_high) * 0.5, ); this.thermostatService.updateCharacteristic( this.hap.Characteristic.TargetHeatingCoolingState, this.hap.Characteristic.TargetHeatingCoolingState.AUTO, ); mode = 'range'; } await this.set({ uuid: this.deviceData.nest_google_device_uuid, hvac_mode: mode }); this.#logModeChange('HomeKit', mode); } } getMode() { // Determine the current target heating/cooling mode for HomeKit based on Nest hvac_mode. // Nest exposes a number of internal states such as ECOHEAT/ECOCOOL/ECORANGE which // should map to the same HomeKit modes as their non-eco equivalents. // // HomeKit only supports the following modes: // OFF, HEAT, COOL, AUTO // // If the thermostat does not support heating or cooling, we force the mode to OFF. let currentMode = null; // Normalise hvac_mode to uppercase safely (can occasionally be undefined) let mode = this.deviceData.hvac_mode?.toUpperCase?.() ?? 'OFF'; // Strip ECO prefix from Nest eco modes (ECOHEAT, ECOCOOL, ECORANGE) mode = mode.replace(/^ECO/, ''); if (mode === 'HEAT') { // Heating mode currentMode = this.hap.Characteristic.TargetHeatingCoolingState.HEAT; } if (mode === 'COOL') { // Cooling mode currentMode = this.hap.Characteristic.TargetHeatingCoolingState.COOL; } if (mode === 'RANGE') { // Nest "range" mode means both heating and cooling thresholds are active currentMode = this.hap.Characteristic.TargetHeatingCoolingState.AUTO; } if (mode === 'OFF' || (this.deviceData.can_cool === false && this.deviceData.can_heat === false)) { // Thermostat is turned off or does not support heating/cooling currentMode = this.hap.Characteristic.TargetHeatingCoolingState.OFF; } return currentMode; } async setTemperature(characteristic, temperature) { if (typeof characteristic !== 'function' || typeof characteristic?.UUID !== 'string') { return; } let mode = this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState).value; let targetKey = undefined; let modeLabel = ''; if ( characteristic.UUID === this.hap.Characteristic.TargetTemperature.UUID && mode !== this.hap.Characteristic.TargetHeatingCoolingState.AUTO ) { targetKey = 'target_temperature'; modeLabel = mode === this.hap.Characteristic.TargetHeatingCoolingState.HEAT ? 'heating' : 'cooling'; } else if ( characteristic.UUID === this.hap.Characteristic.HeatingThresholdTemperature.UUID && mode === this.hap.Characteristic.TargetHeatingCoolingState.AUTO ) { targetKey = 'target_temperature_low'; modeLabel = 'heating'; } else if ( characteristic.UUID === this.hap.Characteristic.CoolingThresholdTemperature.UUID && mode === this.hap.Characteristic.TargetHeatingCoolingState.AUTO ) { targetKey = 'target_temperature_high'; modeLabel = 'cooling'; } this.thermostatService.updateCharacteristic(characteristic, temperature); if (targetKey !== undefined) { // Only set a target temperature if we've determined whicb Nest/Google data key to change await this.set({ uuid: this.deviceData.nest_google_device_uuid, [targetKey]: temperature }); this.#logTemperatureChange( 'HomeKit', modeLabel, temperature, this.deviceData.hvac_mode?.toUpperCase?.().includes('ECO') === true, this.deviceData.temperature_scale?.toUpperCase?.() === 'F' ? 'F' : 'C', ); } } getTemperature(characteristic) { // Return the correct temperature value for the requested HomeKit characteristic. // Nest exposes three temperature targets: // target_temperature -> single heat/cool mode // target_temperature_low -> heating threshold (range mode) // target_temperature_high -> cooling threshold (range mode) // // HomeKit queries different characteristics depending on the thermostat mode, // so we return the appropriate value and fallback where needed. if (typeof characteristic !== 'function' || typeof characteristic?.UUID !== 'string') { return null; } let currentTemperature = { // HomeKit TargetTemperature is used in HEAT or COOL modes. // If not present, fallback to either threshold temperature. [this.hap.Characteristic.TargetTemperature.UUID]: this.deviceData.target_temperature ?? this.deviceData.target_temperature_low ?? this.deviceData.target_temperature_high, // Heating threshold used in AUTO/RANGE mode. // Fallback to target_temperature if Nest did not provide a low value. [this.hap.Characteristic.HeatingThresholdTemperature.UUID]: this.deviceData.target_temperature_low ?? this.deviceData.target_temperature, // Cooling threshold used in AUTO/RANGE mode. // Fallback to target_temperature if Nest did not provide a high value. [this.hap.Characteristic.CoolingThresholdTemperature.UUID]: this.deviceData.target_temperature_high ?? this.deviceData.target_temperature, }[characteristic.UUID]; // HomeKit requires a finite numeric value or null if (Number.isFinite(Number(currentTemperature)) === false) { return null; } // Clamp temperature to HomeKit supported range currentTemperature = Math.min(Math.max(Number(currentTemperature), THERMOSTAT_MIN_TEMPERATURE), THERMOSTAT_MAX_TEMPERATURE); return currentTemperature; } setChildlock(pin, value) { this.thermostatService.updateCharacteristic(this.hap.Characteristic.LockPhysicalControls, value); // Update HomeKit with value if (value === this.hap.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED) { // TODO - Implement PIN