homebridge-nest-accfactory
Version:
Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras
589 lines (507 loc) • 20.9 kB
JavaScript
// Nest Temperature Sensor - HomeKit integration
// Part of homebridge-nest-accfactory
//
// HomeKit accessory implementation for Nest Temperature Sensor devices.
// Provides temperature monitoring, battery status, online/offline detection,
// and integration with thermostat remote sensing.
//
// Responsibilities:
// - Expose current temperature via HomeKit TemperatureSensor service
// - Synchronise sensor availability and active status with HomeKit
// - Monitor battery level and low battery conditions
// - Determine online/offline state based on last update timestamps
// - Record temperature history for Eve Home integration
//
// Services:
// - TemperatureSensor (primary service)
// - Battery (hidden, linked to temperature service)
//
// Features:
// - Real-time temperature updates with HomeKit synchronisation
// - Active sensor indication when paired with thermostat (RCS support)
// - Battery monitoring with low battery alerts
// - Online/offline detection using timestamp-based validation (4-hour threshold)
// - Eve Home history integration (5-minute interval recording)
//
// Notes:
// - Supports both Nest and Google APIs
// - Sensors may exist in one or both APIs; data is merged and deduplicated
// - Serial number is used as the unique key across APIs
// - Temperature values are normalised before HomeKit presentation
//
// Data Translation:
// - Raw data is mapped using TEMPSENSOR_FIELD_MAP
// - processRawData() builds device objects from thermostat-linked sensors
// - Google and Nest sources are merged and deduplicated by serialNumber
// - Field mapping isolates upstream API differences from HomeKit representation
//
// Mark Hulskamp
;
// Define our modules
import HomeKitDevice from '../HomeKitDevice.js';
import { adjustTemperature, scaleValue } from '../utils.js';
import { buildMappedObject, createMappingContext } from '../translator.js';
// Define constants
import { LOW_BATTERY_LEVEL, DATA_SOURCE, PROTOBUF_RESOURCES, DEVICE_TYPE } from '../consts.js';
export default class NestTemperatureSensor extends HomeKitDevice {
static TYPE = DEVICE_TYPE.TEMPSENSOR;
static VERSION = '2026.04.26'; // Code version
batteryService = undefined;
temperatureService = undefined;
// Class functions
onAdd() {
// Setup temperature service if not already present on the accessory and link it to the Eve app if configured to do so
this.temperatureService = this.addHKService(this.hap.Service.TemperatureSensor, '', 1, {});
this.temperatureService.setPrimaryService();
// Setup battery service if not already present on the accessory
this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1);
this.batteryService.setHiddenService(true);
this.temperatureService.addLinkedService(this.batteryService);
}
onRemove() {
this.accessory.removeService(this.temperatureService);
this.accessory.removeService(this.batteryService);
this.temperatureService = undefined;
this.batteryService = undefined;
}
onUpdate(deviceData) {
if (
typeof deviceData !== 'object' ||
deviceData?.constructor !== Object ||
this.temperatureService === undefined ||
this.batteryService === undefined
) {
return;
}
// If device isn't online report in HomeKit
this.temperatureService.updateCharacteristic(
this.hap.Characteristic.StatusFault,
deviceData.online === true ? this.hap.Characteristic.StatusFault.NO_FAULT : this.hap.Characteristic.StatusFault.GENERAL_FAULT,
);
// Status active: if linked to thermostat, must be online AND the active sensor; otherwise just online
this.temperatureService.updateCharacteristic(
this.hap.Characteristic.StatusActive,
typeof deviceData?.associated_thermostat === 'string' && deviceData.associated_thermostat !== ''
? deviceData.online === true && deviceData?.active_sensor === true
: deviceData.online === true,
);
// Update temperature
this.temperatureService.updateCharacteristic(this.hap.Characteristic.CurrentTemperature, deviceData.current_temperature);
// Update battery level and status
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, this.hap.Characteristic.ChargingState.NOT_CHARGEABLE);
// If we have the history service running and temperature has changed to previous in past 5mins
this.history(
this.temperatureService,
{
temperature: deviceData.current_temperature,
},
{ timegap: 300, force: true },
);
}
}
// Temperature sensor field translation map
// Maps raw source data -> normalised device fields
// - fields: top-level raw fields this mapping depends on (for delta updates)
// - translate: converts raw -> candidate value
// - merge: combines source values into the final normalised value
// IMPORTANT:
// `raw` in this map represents the temperature sensor resource itself.
// Thermostat relationship details are passed separately via mapping context.
const TEMPSENSOR_FIELD_MAP = {
// Identity fields
serialNumber: {
required: true,
google: {
fields: [],
translate: ({ raw }) => {
let value = raw?.value;
return typeof value?.device_identity?.serialNumber === 'string' && value.device_identity.serialNumber !== ''
? value.device_identity.serialNumber
: undefined;
},
},
nest: {
fields: [],
translate: ({ raw }) => {
let value = raw?.value;
return typeof value?.serial_number === 'string' && value.serial_number !== '' ? value.serial_number : undefined;
},
},
},
nest_google_device_uuid: {
required: true,
google: {
fields: [],
translate: ({ sensorUUID }) => sensorUUID,
},
nest: {
fields: [],
translate: ({ sensorUUID }) => sensorUUID,
},
},
nest_google_home_uuid: {
google: {
fields: [],
translate: ({ homeUUID }) => homeUUID,
},
nest: {
fields: [],
translate: ({ homeUUID }) => homeUUID,
},
},
// Naming / descriptive fields
model: {
required: true,
google: {
fields: ['device_info', 'device_identity'],
translate: ({ raw }) => {
let value = raw?.value;
let typeName = value?.device_info?.typeName ?? '';
let productId = value?.device_identity?.productIdDescription?.literal ?? '';
return typeName === 'nest.resource.NestKryptoniteResource' && productId === 'KRX'
? 'Temperature Sensor (2nd gen)'
: typeName === 'nest.resource.NestKryptoniteResource' && productId === 'KR1'
? 'Temperature Sensor (1st gen)'
: 'Temperature Sensor (unknown)';
},
},
nest: {
fields: ['model'],
translate: ({ raw }) => {
let value = raw?.value;
return value?.model === 'KRX'
? 'Temperature Sensor (2nd gen)'
: value?.model === 'KR1'
? 'Temperature Sensor (1st gen)'
: 'Temperature Sensor (unknown)';
},
},
},
description: {
required: true,
google: {
fields: ['label', 'device_info', 'device_located_settings'],
related: ['located_annotations'],
translate: ({ rawData, raw }) => {
let value = raw?.value;
let description = String(value?.label?.label ?? '').trim();
let location = String(value?.device_located_settings?.whereLabel?.literal ?? '').trim();
let pairerId = value?.device_info?.pairerId?.resourceId;
let whereId = value?.device_located_settings?.whereAnnotationRid?.resourceId;
if (location === '' && typeof pairerId === 'string' && pairerId !== '' && typeof whereId === 'string' && whereId !== '') {
let wheres = [
...Object.values(rawData?.[pairerId]?.value?.located_annotations?.predefinedWheres || {}),
...Object.values(rawData?.[pairerId]?.value?.located_annotations?.customWheres || {}),
];
location = String(wheres.find((where) => where?.whereId?.resourceId === whereId)?.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: ['description', 'structure_id', 'where_id'],
related: ['wheres'],
translate: ({ rawData, raw }) => {
let value = raw?.value;
let description = String(value?.description ?? '').trim();
let location = '';
let structureId = value?.structure_id;
let whereId = value?.where_id;
if (typeof structureId === 'string' && structureId !== '' && typeof whereId === 'string' && whereId !== '') {
location = String(
rawData?.['where.' + structureId]?.value?.wheres?.find((where) => where?.where_id === whereId)?.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);
},
},
},
battery_level: {
required: true,
google: {
fields: ['battery'],
translate: ({ raw }) => {
let value = raw?.value;
return Number.isFinite(Number(value?.battery?.assessedVoltage?.value)) === true
? Math.round(scaleValue(Number(value.battery.assessedVoltage.value), 2.5, 3.2, 0, 100))
: undefined;
},
},
nest: {
fields: ['battery_level'],
translate: ({ raw }) => {
let value = raw?.value;
return Number.isFinite(Number(value?.battery_level)) === true
? Math.round(scaleValue(Number(value.battery_level), 0, 100, 0, 100))
: undefined;
},
},
},
current_temperature: {
required: true,
google: {
fields: ['current_temperature'],
translate: ({ raw }) => {
let value = raw?.value;
return Number.isFinite(Number(value?.current_temperature?.temperatureValue?.temperature?.value)) === true
? adjustTemperature(Number(value.current_temperature.temperatureValue.temperature.value), 'C', 'C', true)
: undefined;
},
},
nest: {
fields: ['current_temperature'],
translate: ({ raw }) => {
let value = raw?.value;
return Number.isFinite(Number(value?.current_temperature)) === true
? adjustTemperature(Number(value.current_temperature), 'C', 'C', true)
: undefined;
},
},
},
online: {
required: true,
google: {
fields: ['last_updated_beacon'],
translate: ({ raw }) => {
let value = raw?.value;
return (
Number.isFinite(Number(value?.last_updated_beacon?.lastBeaconTime?.seconds)) === true &&
Math.floor(Date.now() / 1000) - Number(value.last_updated_beacon.lastBeaconTime.seconds) < 3600 * 4
);
},
},
nest: {
fields: ['last_updated_at'],
translate: ({ raw }) => {
let value = raw?.value;
return (
Number.isFinite(Number(value?.last_updated_at)) === true &&
Math.floor(Date.now() / 1000) - Number(value.last_updated_at) < 3600 * 4
);
},
},
},
associated_thermostat: {
google: {
fields: [],
translate: ({ parentUUID }) => parentUUID,
},
nest: {
fields: [],
translate: ({ parentUUID }) => parentUUID,
},
},
active_sensor: {
google: {
fields: [],
translate: ({ active_sensor }) => active_sensor === true,
},
nest: {
fields: [],
translate: ({ active_sensor }) => active_sensor === true,
},
},
};
// Function to process our RAW Nest or Google data for temperature sensor 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;
}
// Temperature sensors are discovered via thermostat resources and may exist in
// Google, Nest, or both. We collect candidate sensors and deduplicate by serial
// number, preferring Google data when the same sensor exists in both APIs.
let devices = {};
let candidates = {};
const storeCandidate = (mappedResult, source, associatedThermostat) => {
let serialNumber = mappedResult?.data?.serialNumber;
if (
mappedResult?.hasRequired !== true ||
typeof mappedResult?.data !== 'object' ||
mappedResult.data?.constructor !== Object ||
typeof serialNumber !== 'string' ||
serialNumber.trim() === ''
) {
return;
}
// Prefer Google candidate when the same sensor exists in both APIs
if (candidates[serialNumber] !== undefined && source !== DATA_SOURCE.GOOGLE) {
return;
}
candidates[serialNumber] = {
type: DEVICE_TYPE.TEMPSENSOR,
softwareVersion: NestTemperatureSensor.VERSION,
manufacturer: 'Nest',
associated_thermostat: associatedThermostat,
...mappedResult.data,
};
};
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 {
// Source type for this thermostat (Google or Nest)
let source = undefined;
// Normalised list of sensors for this thermostat
// Each entry contains sensor UUID and whether it is the active RCS sensor
let sensors = [];
// Google uses pairerId as the home identifier
let homeUUID = undefined;
// Nest stores RCS data in a separate resource
let rcsSettings = undefined;
// Extract temperature sensors linked to a Google thermostat
if (
value?.source === DATA_SOURCE.GOOGLE &&
value?.value?.configuration_done?.deviceReady === true &&
rawData?.[value?.value?.device_info?.pairerId?.resourceId] !== undefined &&
Array.isArray(value?.value?.remote_comfort_sensing_settings?.associatedRcsSensors) === true
) {
source = DATA_SOURCE.GOOGLE;
// Home UUID used for grouping and config matching
homeUUID = value.value.device_info.pairerId.resourceId;
// Build normalised sensor list from Google RCS configuration
sensors = value.value.remote_comfort_sensing_settings.associatedRcsSensors.map((sensor) => ({
sensorUUID: sensor?.deviceId?.resourceId,
// Active sensor is defined by the thermostat's current RCS selection
active_sensor:
value?.value?.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor?.resourceId ===
sensor?.deviceId?.resourceId,
}));
}
// Extract temperature sensors linked to a Nest thermostat
if (
value?.source === DATA_SOURCE.NEST &&
Array.isArray(rawData?.['rcs_settings.' + value?.value?.serial_number]?.value?.associated_rcs_sensors) === true
) {
source = DATA_SOURCE.NEST;
// Full RCS settings object for this thermostat
rcsSettings = rawData['rcs_settings.' + value.value.serial_number]?.value;
// Build normalised sensor list from Nest RCS configuration
sensors = rcsSettings.associated_rcs_sensors.map((sensorUUID) => ({
sensorUUID: sensorUUID,
// Active sensor is defined by active_rcs_sensors list
active_sensor: rcsSettings?.active_rcs_sensors?.includes?.(sensorUUID) === true,
}));
}
// Process each discovered sensor using the same mapping pipeline
sensors.forEach((sensor) => {
let sensorUUID = sensor?.sensorUUID;
// Raw sensor resource, passed as `raw` into the translator
let sensorRaw = rawData?.[sensorUUID];
// Convenience reference to sensor payload
let sensorValue = sensorRaw?.value;
// Validate sensor resource exists and is well-formed
if (
typeof sensorUUID !== 'string' ||
sensorUUID === '' ||
typeof sensorRaw !== 'object' ||
sensorRaw?.constructor !== Object ||
typeof sensorValue !== 'object' ||
sensorValue?.constructor !== Object
) {
return;
}
// Google sensors must have a valid home UUID
if (source === DATA_SOURCE.GOOGLE && typeof homeUUID !== 'string') {
return;
}
// Nest sensors must have valid structure and where mapping for description resolution
if (
source === DATA_SOURCE.NEST &&
((sensorValue?.structure_id?.trim?.() ?? '') === '' || typeof rawData?.['where.' + sensorValue.structure_id] !== 'object')
) {
return;
}
// Build mapped sensor object
// `raw` represents the sensor resource inside TEMPSENSOR_FIELD_MAP
storeCandidate(
buildMappedObject(
TEMPSENSOR_FIELD_MAP,
createMappingContext(rawData, sensorUUID, source === DATA_SOURCE.GOOGLE ? { google: sensorRaw } : { nest: sensorRaw }, {
// Unique sensor identifier
sensorUUID: sensorUUID,
// Home identifier differs between Google and Nest
homeUUID: source === DATA_SOURCE.GOOGLE ? homeUUID : 'structure.' + sensorValue.structure_id,
// Thermostat this sensor is associated with
parentUUID: object_key,
// Pairer UUID (only present for Google devices)
pairerUUID: source === DATA_SOURCE.GOOGLE ? sensorValue?.device_info?.pairerId?.resourceId : undefined,
// Whether this sensor is currently active for RCS
active_sensor: sensor.active_sensor === true,
}),
changedData instanceof Map ? changedData.get(sensorUUID)?.fields : undefined,
),
source,
object_key,
);
});
} catch (error) {
log?.error?.('Error processing temperature sensor data for "%s": %s', object_key, String(error));
}
});
Object.values(candidates).forEach((candidate) => {
if (deviceType !== undefined && (typeof deviceType !== 'string' || deviceType === '' || candidate.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?.() === candidate?.serialNumber?.toUpperCase?.(),
);
let homeOptions = config?.homes?.find(
(home) =>
home?.nest_home_uuid?.toUpperCase?.() === candidate?.nest_google_home_uuid?.toUpperCase?.() ||
home?.google_home_uuid?.toUpperCase?.() === candidate?.nest_google_home_uuid?.toUpperCase?.(),
);
// Insert any extra options we've read in from configuration file for this device
candidate.eveHistory =
deviceOptions?.eveHistory !== undefined ? deviceOptions.eveHistory === true : config?.options?.eveHistory === true;
// Process additional exclusion details
candidate.excluded =
deviceOptions?.exclude === true ||
(deviceOptions?.exclude !== false &&
(homeOptions?.exclude === true || (homeOptions?.exclude !== false && config?.options?.exclude === true)));
// Store full device
devices[candidate.serialNumber] = {
full: true,
data: candidate,
};
});
return devices;
}