UNPKG

homebridge-nest-accfactory

Version:

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

722 lines (637 loc) 27.1 kB
// Nest HeatLink - HomeKit integration // Part of homebridge-nest-accfactory // // HomeKit accessory implementation for Nest Heat Link devices. // Provides hot water temperature control and/or boost heating control // depending on device capabilities and configuration. // // Responsibilities: // - Expose hot water temperature control via Thermostat service (when supported) // - Expose hot water boost control via Switch service (when supported) // - Synchronise temperature, heating state, and boost state with HomeKit // - Route control changes to the associated Nest thermostat device // - Record heating activity and temperature history for Eve Home integration // // Services: // - Thermostat (optional, for hot water temperature control) // - Switch (optional, for hot water boost control) // // Features: // - Configurable temperature range (min/max per device) // - Hot water boost with configurable duration (mapped to Nest presets) // - Real-time water temperature monitoring // - Heating active state reporting (HEAT / OFF) // - Temperature unit synchronisation (Celsius / Fahrenheit) // - Online/offline status reporting // - Eve Home history integration (temperature, target, heating state) // // Notes: // - Heat Link devices are controlled via their associated thermostat // - Not all devices support both temperature control and boost control // - Services are created or removed dynamically based on device capabilities // - Temperature values are normalised via shared utility helpers // // Data Translation: // - Raw Nest and Google data is mapped using HEATLINK_FIELD_MAP // - Google API data is preferred, with Nest as fallback // - processRawData() builds device objects and applies configuration overrides // // Mark Hulskamp 'use strict'; // Define our modules import HomeKitDevice from '../HomeKitDevice.js'; import { processSoftwareVersion, adjustTemperature, parseDurationToSeconds } from '../utils.js'; import { buildMappedObject, createMappingContext } from '../translator.js'; // Define constants import { DATA_SOURCE, DEVICE_TYPE, HOTWATER_MAX_TEMPERATURE, HOTWATER_MIN_TEMPERATURE, PROTOBUF_RESOURCES, HOTWATER_BOOST_TIMES, } from '../consts.js'; export default class NestHeatlink extends HomeKitDevice { static TYPE = DEVICE_TYPE.HEATLINK; static VERSION = '2026.05.06'; // Code version thermostatService = undefined; // Hotwater temperature control switchService = undefined; // Hotwater heating boost control onAdd() { // Patch to avoid characteristic errors when setting initial property ranges this.hap.Characteristic.TargetTemperature.prototype.getDefaultValue = () => { return this.deviceData.hotwaterMinTemp; // start at minimum heating threshold }; this.hap.Characteristic.TargetHeatingCoolingState.prototype.getDefaultValue = () => { return this.hap.Characteristic.TargetHeatingCoolingState.HEAT; // Only heating }; // If the heatlink supports hotwater temperature control // Setup the thermostat service if not already present on the accessory, and link it to the Eve app if configured to do so if ( this.deviceData?.has_hot_water_temperature === true && Number.isFinite(Number(this.deviceData?.current_water_temperature)) === true ) { this.#setupHotwaterTemperature(); } if ( this.deviceData?.has_hot_water_temperature === false || Number.isFinite(Number(this.deviceData?.current_water_temperature)) === false ) { // No longer have usable hot water temperature control, so remove any existing thermostat service this.thermostatService = this.accessory.getService(this.hap.Service.Thermostat); if (this.thermostatService !== undefined) { this.accessory.removeService(this.thermostatService); } this.thermostatService = undefined; } // Setup hotwater boost heating service if supported by the thermostat and not already present on the accessory if (this.deviceData?.has_hot_water_control === true) { this.#setupHotwaterBoost(); } if (this.deviceData?.has_hot_water_control === false) { // No longer have hotwater heating configured and service present, so removed it this.switchService = this.accessory.getService(this.hap.Service.Switch); if (this.switchService !== undefined) { this.accessory.removeService(this.switchService); } this.switchService = undefined; } // Extra setup details for output this.switchService !== undefined && this.postSetupDetail('Boost time ' + this.#logHotwaterBoostTime(this.deviceData.hotwaterBoostTime)); this.thermostatService !== undefined && this.postSetupDetail('Temperature control (' + this.deviceData.hotwaterMinTemp + '–' + this.deviceData.hotwaterMaxTemp + '°C)'); } onRemove() { this.accessory.removeService(this.thermostatService); this.accessory.removeService(this.switchService); this.thermostatService = undefined; this.switchService = undefined; } onUpdate(deviceData) { if (typeof deviceData !== 'object') { return; } // TODO dynamic changes to hotwater setup ie: boost control and temperature control if (this.thermostatService !== undefined) { // Update when we have hot water temperature control this.thermostatService.updateCharacteristic( this.hap.Characteristic.TemperatureDisplayUnits, deviceData.temperature_scale.toUpperCase() === 'C' ? this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS : this.hap.Characteristic.TemperatureDisplayUnits.FAHRENHEIT, ); this.thermostatService.updateCharacteristic(this.hap.Characteristic.CurrentTemperature, deviceData.current_water_temperature); this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, deviceData.hot_water_temperature); this.thermostatService.updateCharacteristic( this.hap.Characteristic.CurrentHeatingCoolingState, deviceData.hot_water_active === true ? this.hap.Characteristic.CurrentHeatingCoolingState.HEAT : this.hap.Characteristic.CurrentHeatingCoolingState.OFF, ); this.thermostatService.updateCharacteristic(this.hap.Characteristic.StatusActive, deviceData.online === true); // Log thermostat metrics to history only if changed to previous recording this.history(this.thermostatService, { status: deviceData.hot_water_active === true ? 2 : 0, // 2 - heating water, 0 - not heating water temperature: deviceData.current_water_temperature, target: deviceData.hot_water_temperature, }); // 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.historyService?.updateEveHome?.(this.thermostatService); } if (this.switchService !== undefined) { // Hotwater boost status on or off this.switchService.updateCharacteristic(this.hap.Characteristic.On, deviceData.hot_water_boost_active === true); } } onMessage(type, message) { if ( typeof type !== 'string' || type === '' || (message !== undefined && (message === null || typeof message !== 'object' || message.constructor !== Object)) ) { return; } if (type === HomeKitDevice?.HISTORY?.GET) { // Extend Eve Thermo GET payload with device state message.attached = this.deviceData.online === true; return message; } } #setupHotwaterTemperature() { this.thermostatService = this.addHKService(this.hap.Service.Thermostat, '', 1, { messages: this.message.bind(this) }); this.thermostatService.setPrimaryService(); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.StatusActive); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TemperatureDisplayUnits, { onSet: (value) => { let unit = value === this.hap.Characteristic.TemperatureDisplayUnits.CELSIUS ? 'C' : 'F'; this.set({ uuid: this.deviceData.associated_thermostat, temperature_scale: unit, }); this.thermostatService.updateCharacteristic(this.hap.Characteristic.TemperatureDisplayUnits, value); this?.log?.info?.('Set temperature units on heat link "%s" to "%s"', this.deviceData.description, unit === 'C' ? '°C' : '°F'); }, 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 this.deviceData.current_water_temperature; }, }); this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TargetTemperature, { props: { minStep: 0.5, minValue: this.deviceData.hotwaterMinTemp, maxValue: this.deviceData.hotwaterMaxTemp, }, onSet: (value) => { if (value !== this.deviceData.hot_water_temperature) { this.set({ uuid: this.deviceData.associated_thermostat, hot_water_temperature: value }); this?.log?.info?.('Set hotwater boiler temperature on heat link "%s" to "%s °C"', this.deviceData.description, value); } }, onGet: () => { return this.deviceData.hot_water_temperature; }, }); // We only support heating for this thermostat service this.addHKCharacteristic(this.thermostatService, this.hap.Characteristic.TargetHeatingCoolingState, { props: { validValues: [this.hap.Characteristic.TargetHeatingCoolingState.HEAT], }, }); } #setupHotwaterBoost() { this.switchService = this.addHKService(this.hap.Service.Switch, '', 1); this.addHKCharacteristic(this.switchService, this.hap.Characteristic.On, { onSet: (value) => { if (value !== this.deviceData.hot_water_boost_active) { this.set({ uuid: this.deviceData.associated_thermostat, hot_water_boost_active: { state: value === true, time: this.deviceData.hotwaterBoostTime }, }); this.switchService.updateCharacteristic(this.hap.Characteristic.On, value); this?.log?.info?.( 'Set hotwater boost heating on heat link "%s" to "%s"', this.deviceData.description, value === true ? 'On for ' + this.#logHotwaterBoostTime(this.deviceData.hotwaterBoostTime) : 'Off', ); } }, onGet: () => { return this.deviceData?.hot_water_boost_active === true; }, }); } #logHotwaterBoostTime(time) { let output = ''; if (Number.isFinite(Number(time)) === true) { output = (time >= 3600 ? Math.floor(time / 3600) + ' hr' + (Math.floor(time / 3600) > 1 ? 's ' : ' ') : '') + Math.floor((time % 3600) / 60) + ' min' + (Math.floor((time % 3600) / 60) !== 1 ? 's' : ''); } return output; } } // Heat Link field translation map // Maps raw source data -> normalised heat link device fields // - fields: top-level raw fields this mapping depends on (for delta updates) // - related: top-level raw fields on related objects this mapping depends on // - translate: converts raw -> final normalised value const HEATLINK_FIELD_MAP = { // Identity fields serialNumber: { required: true, google: { fields: [], translate: ({ raw }) => (raw?.value?.heat_link?.heatLinkSerialNumber?.value?.trim?.() ?? '') !== '' ? raw.value.heat_link.heatLinkSerialNumber.value.trim().toUpperCase() : undefined, }, nest: { fields: [], translate: ({ raw }) => (raw?.value?.heat_link_serial_number?.trim?.() ?? '') !== '' ? raw.value.heat_link_serial_number.trim().toUpperCase() : undefined, }, }, nest_google_device_uuid: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, nest: { fields: [], translate: ({ objectKey }) => objectKey, }, }, nest_google_home_uuid: { google: { fields: ['device_info'], translate: ({ raw }) => raw?.value?.device_info?.pairerId?.resourceId, }, nest: { fields: ['serial_number'], related: ['structure'], translate: ({ rawData, raw }) => rawData?.['link.' + raw?.value?.serial_number]?.value?.structure, }, }, // Model identification model: { required: true, google: { fields: ['heat_link'], translate: ({ raw }) => { let model = raw?.value?.heat_link?.heatLinkModel?.value ?? ''; if (model.startsWith('Amber-2') === true) { return 'Heat Link for Learning Thermostat (3rd gen, EU)'; } if (model.startsWith('Amber-1') === true) { return 'Heat Link for Learning Thermostat (2nd gen, EU)'; } if (model.includes('Agate') === true) { return 'Heat Link for Thermostat E (1st gen, EU)'; } return model !== '' ? 'Heat Link (unknown - ' + model + ')' : undefined; }, }, nest: { fields: ['heat_link_model'], translate: ({ raw }) => { let model = raw?.value?.heat_link_model ?? ''; if (model.startsWith('Amber-2') === true) { return 'Heat Link for Learning Thermostat (3rd gen, EU)'; } if (model.startsWith('Amber-1') === true) { return 'Heat Link for Learning Thermostat (2nd gen, EU)'; } if (model.includes('Agate') === true) { return 'Heat Link for Thermostat E (1st gen, EU)'; } return model !== '' ? 'Heat Link (unknown - ' + model + ')' : undefined; }, }, }, softwareVersion: { required: true, google: { fields: ['heat_link'], translate: ({ raw }) => typeof raw?.value?.heat_link?.heatLinkSwVersion?.value === 'string' && raw.value.heat_link.heatLinkSwVersion.value.trim() !== '' ? processSoftwareVersion(raw.value.heat_link.heatLinkSwVersion.value) : undefined, }, nest: { fields: ['heat_link_sw_version'], translate: ({ raw }) => typeof raw?.value?.heat_link_sw_version === 'string' && raw.value.heat_link_sw_version.trim() !== '' ? processSoftwareVersion(raw.value.heat_link_sw_version) : undefined, }, }, // Thermostat association associated_thermostat: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, nest: { fields: [], translate: ({ objectKey }) => objectKey, }, }, temperature_scale: { required: true, google: { fields: ['display_settings'], translate: ({ raw }) => (raw?.value?.display_settings?.temperatureScale === 'TEMPERATURE_SCALE_F' ? 'F' : 'C'), }, nest: { fields: ['temperature_scale'], translate: ({ raw }) => (raw?.value?.temperature_scale?.toUpperCase?.() === 'F' ? 'F' : 'C'), }, }, online: { required: true, google: { fields: ['liveness'], translate: ({ raw }) => raw?.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE', }, nest: { fields: ['serial_number'], related: ['online'], translate: ({ rawData, raw }) => rawData?.['track.' + raw?.value?.serial_number]?.value?.online === true, }, }, has_hot_water_control: { required: true, google: { fields: ['hvac_equipment_capabilities'], translate: ({ raw }) => raw?.value?.hvac_equipment_capabilities?.hasHotWaterControl === true, }, nest: { fields: ['has_hot_water_control'], translate: ({ raw }) => raw?.value?.has_hot_water_control === true, }, }, hot_water_active: { google: { fields: ['hot_water_trait'], translate: ({ raw }) => raw?.value?.hot_water_trait?.boilerActive === true, }, nest: { fields: ['hot_water_active'], translate: ({ raw }) => raw?.value?.hot_water_active === true, }, }, hot_water_boost_active: { required: true, google: { fields: ['hot_water_settings'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.hot_water_settings?.boostTimerEnd?.seconds)) === true && Number(raw.value.hot_water_settings.boostTimerEnd.seconds) > 0, }, nest: { fields: ['hot_water_boost_time_to_end'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.hot_water_boost_time_to_end)) === true && Number(raw.value.hot_water_boost_time_to_end) > 0, }, }, has_hot_water_temperature: { required: true, google: { fields: ['hvac_equipment_capabilities'], translate: ({ raw }) => raw?.value?.hvac_equipment_capabilities?.hasHotWaterTemperature === true, }, nest: { fields: ['has_hot_water_temperature'], translate: ({ raw }) => raw?.value?.has_hot_water_temperature === true, }, }, current_water_temperature: { google: { fields: ['hot_water_trait'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.hot_water_trait?.temperature?.value)) === true ? adjustTemperature(Number(raw.value.hot_water_trait.temperature.value), 'C', 'C', true) : undefined, }, nest: { fields: ['current_water_temperature'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.current_water_temperature)) === true ? adjustTemperature(Number(raw.value.current_water_temperature), 'C', 'C', true) : undefined, }, }, hot_water_temperature: { google: { fields: ['hot_water_settings'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.hot_water_settings?.temperature?.value)) === true ? adjustTemperature(Number(raw.value.hot_water_settings.temperature.value), 'C', 'C', true) : undefined, }, nest: { fields: ['hot_water_temperature'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.hot_water_temperature)) === true ? adjustTemperature(Number(raw.value.hot_water_temperature), 'C', 'C', true) : undefined, }, }, description: { required: true, google: { fields: ['label', 'device_info', 'device_located_settings'], related: ['located_annotations'], translate: ({ rawData, raw }) => { let description = String(raw?.value?.label?.label ?? '').trim(); let location = String(raw?.value?.device_located_settings?.whereLabel?.literal ?? '').trim(); if (location === '') { location = String( [ ...Object.values( rawData?.[raw?.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.predefinedWheres || {}, ), ...Object.values(rawData?.[raw?.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.customWheres || {}), ].find((where) => where?.whereId?.resourceId === raw?.value?.device_located_settings?.whereAnnotationRid?.resourceId)?.label ?.literal ?? '', ).trim(); } if (description.toUpperCase() === location.toUpperCase()) { location = ''; } if (description === '' && location !== '') { description = location; location = ''; } if (description === '' && location === '') { description = 'unknown description'; } return HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location); }, }, nest: { fields: ['serial_number', 'where_id'], related: ['name', 'wheres', 'structure'], translate: ({ rawData, raw }) => { let description = String(rawData?.['shared.' + raw?.value?.serial_number]?.value?.name ?? '').trim(); let location = String( rawData?.['where.' + rawData?.['link.' + raw?.value?.serial_number]?.value?.structure?.split?.('.')[1]]?.value?.wheres?.find( (where) => where?.where_id === raw?.value?.where_id, )?.name ?? '', ).trim(); if (description.toUpperCase() === location.toUpperCase()) { location = ''; } if (description === '' && location !== '') { description = location; location = ''; } if (description === '' && location === '') { description = 'unknown description'; } return HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location); }, }, }, }; // Function to process our RAW Nest or Google data for heat link devices export function processRawData(log, rawData, config, deviceType = undefined, changedData = undefined) { if ( rawData === null || typeof rawData !== 'object' || rawData?.constructor !== Object || typeof config !== 'object' || config?.constructor !== Object ) { return; } // Process data for any heat link devices found via thermostat resources let devices = {}; Object.entries(rawData) .filter( ([key, value]) => key.startsWith('device.') === true || (key.startsWith('DEVICE_') === true && PROTOBUF_RESOURCES.THERMOSTAT.includes(value?.value?.device_info?.typeName) === true), ) .forEach(([object_key, value]) => { try { // Only process supported Google or Nest thermostat resources that can expose Heat Link data if ( (value?.source !== DATA_SOURCE.GOOGLE && value?.source !== DATA_SOURCE.NEST) || (value?.source === DATA_SOURCE.GOOGLE && (value?.value?.configuration_done?.deviceReady !== true || typeof rawData?.[value?.value?.device_info?.pairerId?.resourceId] !== 'object' || ['HEAT_LINK_CONNECTION_TYPE_ON_OFF', 'HEAT_LINK_CONNECTION_TYPE_OPENTHERM'].some( (type) => value?.value?.heat_link_settings?.heatConnectionType === type || value?.value?.heat_link_settings?.hotWaterConnectionType === type, ) !== true)) || (value?.source === DATA_SOURCE.NEST && (typeof rawData?.['track.' + value?.value?.serial_number] !== 'object' || typeof rawData?.['shared.' + value?.value?.serial_number] !== 'object')) ) { return; } // Map raw device data into our normalised heat link schema let mappedResult = buildMappedObject( HEATLINK_FIELD_MAP, createMappingContext(rawData, object_key, { nest: value?.source === DATA_SOURCE.NEST ? value : undefined, google: value?.source === DATA_SOURCE.GOOGLE ? value : undefined, }), changedData instanceof Map ? changedData.get(object_key)?.fields : undefined, ); let serialNumber = mappedResult?.data?.serialNumber; let existingDevice = devices[serialNumber]; // If we have all required fields, build the full heat link device data object if (mappedResult?.hasRequired === true) { let tempDevice = { type: DEVICE_TYPE.HEATLINK, manufacturer: 'Nest', ...mappedResult.data, }; // Respect requested device type if specified if (deviceType !== undefined && (typeof deviceType !== 'string' || deviceType === '' || tempDevice.type !== deviceType)) { return; } // Check for any device or home configuration options that match this device // We'll use the serial number to match against device options, and the home uuid to match against home options let deviceOptions = config?.devices?.find( (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(), ); let homeOptions = config?.homes?.find( (home) => home?.nest_home_uuid?.toUpperCase?.() === tempDevice?.nest_google_home_uuid?.toUpperCase?.() || home?.google_home_uuid?.toUpperCase?.() === tempDevice?.nest_google_home_uuid?.toUpperCase?.(), ); // Insert configuration options tempDevice.eveHistory = deviceOptions?.eveHistory !== undefined ? deviceOptions.eveHistory === true : config?.options?.eveHistory === true; // Hot water boost duration allowed by Nest app presets tempDevice.hotwaterBoostTime = parseDurationToSeconds(deviceOptions?.hotwaterBoostTime, { defaultValue: 1800, min: 1800, max: 7200, }); tempDevice.hotwaterBoostTime = HOTWATER_BOOST_TIMES.reduce((a, b) => Math.abs(tempDevice.hotwaterBoostTime - a) < Math.abs(tempDevice.hotwaterBoostTime - b) ? a : b, ); // Configurable temperature bounds tempDevice.hotwaterMinTemp = Number.isFinite(Number(deviceOptions?.hotwaterMinTemp)) === true ? adjustTemperature(deviceOptions.hotwaterMinTemp, 'C', 'C', true) : HOTWATER_MIN_TEMPERATURE; tempDevice.hotwaterMaxTemp = Number.isFinite(Number(deviceOptions?.hotwaterMaxTemp)) === true ? adjustTemperature(deviceOptions.hotwaterMaxTemp, 'C', 'C', true) : HOTWATER_MAX_TEMPERATURE; // Process additional exclusion details tempDevice.excluded = deviceOptions?.exclude === true || (deviceOptions?.exclude !== false && (homeOptions?.exclude === true || (homeOptions?.exclude !== false && config?.options?.exclude === true))); // Store full device // Full always overrides partial if present if (existingDevice?.full !== true) { devices[tempDevice.serialNumber] = { full: true, data: tempDevice, }; } } // Refresh existing device reference after potential full insert existingDevice = devices[serialNumber]; // Only store partial data if nothing has already been stored for this serial in this pass. // A later full payload will replace an earlier partial payload. if ( mappedResult?.hasRequired === false && serialNumber !== undefined && typeof mappedResult?.data === 'object' && mappedResult.data?.constructor === Object && Object.keys(mappedResult.data).length !== 0 && existingDevice === undefined ) { devices[serialNumber] = { full: false, data: mappedResult.data, }; } } catch (error) { log?.error?.('Error processing heat link data for "%s": %s', object_key, String(error)); } }); return devices; }