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
JavaScript
// 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