UNPKG

homebridge-nest-accfactory

Version:

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

273 lines (246 loc) 9.94 kB
// Nest Home/Away Sensor - HomeKit integration // Part of homebridge-nest-accfactory // // Virtual HomeKit accessory representing Nest structure home/away status. // Provides a single occupancy sensor that reflects whether the structure // is currently marked as Home or Away by upstream Nest / Google APIs. // // Responsibilities: // - Create a virtual Home/Away accessory per structure/home // - Expose structure occupancy state via HomeKit OccupancySensor service // - Normalise Nest and Google home/away state into a unified HomeKit model // - Update HomeKit occupancy status when upstream structure mode changes // // Services: // - OccupancySensor // // Features: // - Structure-level virtual accessory (not tied to a physical Nest device) // - Real-time HomeKit characteristic updates // - Supports both Nest and Google APIs // - Stable synthetic serial number derived from structure identity // // Notes: // - This is a virtual device (not a physical Nest device) // - One Home/Away accessory is created per structure/home // - Occupancy detected = structure is Home // - Occupancy not detected = structure is Away // - Google and Nest API data are both supported and normalised // // Data Translation: // - Raw data is mapped using HOMEAWAY_FIELD_MAP // - processRawData() builds virtual devices from structure-level data // - Nest and Google sources are normalised into a unified HomeKit model // - Serial number is derived from structure ID with a virtual device type prefix // // Mark Hulskamp 'use strict'; // Define our modules import HomeKitDevice from '../HomeKitDevice.js'; import { crc24 } from '../utils.js'; import { buildMappedObject, createMappingContext } from '../translator.js'; // Define constants import { DATA_SOURCE, DEVICE_TYPE, NESTLABS_MAC_PREFIX } from '../consts.js'; export default class NestHomeAway extends HomeKitDevice { static TYPE = DEVICE_TYPE.HOMEAWAY; static VERSION = '2026.04.26'; // Code version occupancyService = undefined; // Class functions onAdd() { // Setup occupancy service if not already present on the accessory this.occupancyService = this.addHKService(this.hap.Service.OccupancySensor, '', 1); } onRemove() { this.accessory.removeService(this.occupancyService); this.occupancyService = undefined; } onUpdate(deviceData) { if (typeof deviceData !== 'object' || deviceData?.constructor !== Object || this.occupancyService === undefined) { return; } // Update for away/home status. Away = no occupancy detected, Home = Occupancy Detected this.occupancyService.updateCharacteristic( this.hap.Characteristic.OccupancyDetected, deviceData.occupancy === true ? this.hap.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : this.hap.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED, ); } } // Home/Away field translation map // Maps raw source data -> normalised home/away 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 const HOMEAWAY_FIELD_MAP = { // Identity fields serialNumber: { required: true, google: { fields: ['structure_info'], translate: ({ raw }) => typeof raw?.value?.structure_info?.rtsStructureId === 'string' && raw.value.structure_info.rtsStructureId.trim() !== '' ? NESTLABS_MAC_PREFIX + crc24(DEVICE_TYPE.HOMEAWAY.toUpperCase() + ':' + raw.value.structure_info.rtsStructureId.trim().toUpperCase()).toUpperCase() : undefined, }, nest: { fields: [], translate: ({ objectKey }) => NESTLABS_MAC_PREFIX + crc24(DEVICE_TYPE.HOMEAWAY.toUpperCase() + ':' + objectKey.toUpperCase()).toUpperCase(), }, }, nest_google_device_uuid: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, nest: { fields: [], translate: ({ objectKey }) => objectKey, }, }, nest_google_home_uuid: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, nest: { fields: [], translate: ({ objectKey }) => objectKey, }, }, // Naming / descriptive fields description: { required: true, merge: ({ values }) => HomeKitDevice.makeValidHKName( typeof values?.google === 'string' && values.google.trim() !== '' ? values.google.trim() : typeof values?.nest === 'string' && values.nest.trim() !== '' ? values.nest.trim() : 'unknown description', ), google: { fields: ['structure_location', 'structure_info'], translate: ({ raw }) => String(raw?.value?.structure_location?.city?.value ?? '').trim() !== '' && String(raw?.value?.structure_location?.state?.value ?? '').trim() !== '' ? String(raw.value.structure_location.city.value).trim() + ' - ' + String(raw.value.structure_location.state.value).trim() : String(raw?.value?.structure_info?.name ?? '').trim() !== '' ? String(raw.value.structure_info.name).trim() : undefined, }, nest: { fields: ['city', 'state', 'name'], translate: ({ raw }) => String(raw?.value?.city ?? '').trim() !== '' && String(raw?.value?.state ?? '').trim() !== '' ? String(raw.value.city).trim() + ' - ' + String(raw.value.state).trim() : String(raw?.value?.name ?? '').trim() !== '' ? String(raw.value.name).trim() : undefined, }, }, // Core required home/away fields occupancy: { required: true, google: { fields: ['structure_mode'], translate: ({ raw }) => raw?.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_HOME', }, nest: { fields: ['away'], translate: ({ raw }) => raw?.value?.away === false, }, }, }; // Function to process our RAW source data for this device type // eslint-disable-next-line no-unused-vars 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; } // 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. let devices = {}; Object.entries(rawData) .filter(([key]) => key.startsWith('structure.') === true || key.startsWith('STRUCTURE_') === true) .forEach(([object_key, value]) => { try { // Map raw structure data into our normalised home/away schema let mappedResult = buildMappedObject( HOMEAWAY_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 a full home/away device if (mappedResult?.hasRequired === true) { let tempDevice = { type: DEVICE_TYPE.HOMEAWAY, model: 'Home/Away', manufacturer: 'Nest', softwareVersion: NestHomeAway.VERSION, // Use class version for consistency ...mappedResult.data, }; // Lookup device-specific and home-level configuration 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?.(), ); // Apply Eve history setting (device overrides global) tempDevice.eveHistory = deviceOptions?.eveHistory !== undefined ? deviceOptions.eveHistory === true : config?.options?.eveHistory === true; // 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]; // If we only have partial data (no required fields yet), store partial // Only if we don't already have a full or partial device 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 home/away data for object "%s": %s', object_key, String(error)); } }); return devices; }