UNPKG

homebridge-nest-accfactory

Version:

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

1,136 lines (986 loc) 96.3 kB
// Overall system communications and device management // Part of homebridge-nest-accfactory // // Core platform manager for coordinating Nest/Google cloud data with // HomeKit device modules. // // Handles platform startup/shutdown, device discovery, raw data aggregation, // protobuf-backed observe/subscribe processing, snapshot coordination, and // routing of updates and commands between cloud APIs and HomeKit devices. // // Responsibilities: // - Initialise validated configuration and device support modules // - Build and start configured Nest/Google account connections // - Observe and subscribe to cloud updates in near real-time // - Aggregate and maintain raw device data from multiple API sources // - Coordinate protobuf-backed camera snapshot requests and responses // - Discover, create, update, and remove supported device instances // - Route HomeKit get/set requests to the correct upstream API // - Build protobuf-backed observe trait subscriptions // - Generate support dumps for troubleshooting when enabled // // Features: // - Multi-account support through the Connections module // - Nest REST API subscribe loop and Google protobuf observe loop // - Shared protobuf schema/type caching via protobuf.js helpers // - Raw data merging across Nest and Google sources // - Promise-based snapshot waiter handling for upload_live_image updates // - Dynamic device module loading and HomeKit category selection // // Notes: // - Account authorisation, token refresh, retry handling, and connection cleanup are handled by connections.js // - HomeKit characteristic and service management is handled by individual device modules // - Camera, thermostat, sensor, and lock behaviour is implemented in device-specific modules // // Architecture: // - Exports the main NestAccfactory platform class // - Maintains raw data cache and tracked HomeKit device instances // - Uses Connections for account/session state and gRPC transport ownership // - Uses shared protobuf helpers for schema/type loading and traversal // - Creates and updates HomeKitDevice-based instances for supported device types // // Code version 2026.05.15 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setTimeout, clearTimeout } from 'node:timers'; import path from 'node:path'; import crypto from 'node:crypto'; import process from 'node:process'; import os from 'node:os'; import { URL } from 'node:url'; // Import our modules import HomeKitDevice from './HomeKitDevice.js'; import Connections from './connections.js'; import { loadDeviceModules, getDeviceHKCategory } from './devices.js'; import { processConfig } from './config.js'; import { adjustTemperature, scaleValue, fetchWrapper } from './utils.js'; import { getProtoTypes } from './protobuf.js'; // Define constants import { MIN_NODE_VERSION, USER_AGENT, __dirname, DATA_SOURCE, DEVICE_TYPE, ACCOUNT_TYPE, NEST_API_BUCKETS, PROTOBUF_RESOURCES, } from './consts.js'; const SNAPSHOT_TIMEOUT = 7000; // Overall HomeKit snapshot timeout const SNAPSHOT_WAIT_TIMEOUT = 3000; // Wait for Google upload_live_image observe update const SNAPSHOT_FETCH_TIMEOUT = 3000; // HTTP fetch timeout for snapshot image const API_STREAM_LOOP_INTERVAL = 1000; // Normal subscribe/observe loop restart delay const API_STREAM_RETRY_INITIAL = 5000; // Initial retry delay after subscribe/observe failure const API_STREAM_RETRY_MAX = 60000; // Maximum retry delay during API outage/failure // We handle the connections to Nest/Google // Perform device management (additions/removals/updates) export default class NestAccfactory { cachedAccessories = []; // Track restored cached accessories // Internal data only for this class #connections = undefined; // Connections manager #rawData = {}; // Cached copy of data from both Nest and Google APIs #trackedDevices = new Map(); // Devices we've created, keyed by serial number #deviceModules = undefined; // No loaded device support modules to start constructor(log, config, api) { this.log = log; this.api = api; // Set the shared HomeKitDevice module logger so device modules don't need it passed in. HomeKitDevice.LOGGER = log; // Validate required version of Node.js that we're running on. // If less than our minimum required version, log an error and stop initialisation let nodeVersion = Number(process.versions.node.split('.')[0]); if (nodeVersion < MIN_NODE_VERSION) { this.log.error( 'We no longer support running on Node.js %s. Please upgrade to Node.js %s or newer. The plugin will not be started.', process.versions.node, MIN_NODE_VERSION, ); return; } // Output some basic info about the plugin starting up, which can be useful for troubleshooting if (config?.options?.debug === true) { log?.warn?.('Verbose logging enabled via configuration'); } // Output some debug info about the system we're running on, which can be useful for troubleshooting this?.log?.debug?.('System: %s %s (%s)', os.platform(), os.release(), os.arch()); this?.log?.debug?.('CPU: %s (%d cores)', os.cpus()?.[0]?.model, os.cpus()?.length); this?.log?.debug?.('Memory: %d MB total', Math.round(os.totalmem() / 1024 / 1024)); this?.log?.debug?.('Node.js: v%s', process.versions.node); // Perform validation on the configuration passed into us and set defaults if not present this.config = processConfig(config, this.log, this.api); api?.on?.('didFinishLaunching', async () => { // We got notified that Homebridge has finished loading // Load device support modules from the plugins folder if not already done this.#deviceModules = await loadDeviceModules(this.log, 'plugins'); // Build runtime connection state now that cached accessories have been restored // and device modules are ready to receive cloud updates. this.#connections = this.#createConnections(); // Check for valid connections, either a Nest and/or Google one specified. Otherwise, return back. if (this.#connections.size === 0) { this?.log?.error?.('No active connections have been specified in the JSON configuration. Please review'); return; } // Start connection lifecycle per configured account. for (let [uuid] of this.#connections.entries()) { this.#connections.start(uuid); } }); api?.on?.('shutdown', async () => { // We got notified that Homebridge is shutting down // Perform cleanup of internal state this.#connections?.shutdown?.(); // Cleanup internal data this.#trackedDevices.clear(); this.#rawData = {}; this.#connections = undefined; this.#deviceModules?.clear?.(); this.cachedAccessories = []; }); } configureAccessory(accessory) { // This gets called from Homebridge each time it restores an accessory from its cache this?.log?.info?.('Loading accessory from cache:', accessory.displayName); let informationService = accessory?.getService?.(this.api.hap.Service.AccessoryInformation); if (informationService === undefined) { // Accessory is missing the required AccessoryInformation service // means it's not going to work and is likely a stale entry in the cache. Remove it and log an error. this?.log?.warn?.('Cached accessory "%s" is missing AccessoryInformation service. Removing from cache', accessory.displayName); try { this.api.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [accessory]); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } return; } // Accessory has the required AccessoryInformation service, so we can add the restored accessory to the accessories cache // This allows us to track if it has already been registered this.cachedAccessories.push(accessory); } #createConnections() { return Connections.fromConfig(this.config, { log: this.log, onAuthorised: async (uuid, connection, details = {}) => { // Notify any camera related devices (camera/doorbell/floodlight) of updated auth details. for (let [, trackedDevice] of this.#trackedDevices) { if (typeof trackedDevice !== 'object' || trackedDevice === null) { continue; } if ( trackedDevice.type !== DEVICE_TYPE.CAMERA && trackedDevice.type !== DEVICE_TYPE.DOORBELL && trackedDevice.type !== DEVICE_TYPE.FLOODLIGHT ) { // Not a camera/doorbell/floodlight device, so skip continue; } try { // Send an update message onto the device so it can update its api access details if needed. await HomeKitDevice.message(trackedDevice.uuid, HomeKitDevice.UPDATE, { apiAccess: connection.cameraAuth, }); } catch (error) { this?.log?.debug?.( 'Unable to update camera auth for tracked device "%s": %s', trackedDevice.uuid, typeof error?.message === 'string' ? error.message : String(error), ); } } // Initial authorisation/re-authorisation should start ingestion loops. // Token refreshes keep existing loops running. if (details?.wasAuthorised !== true) { this.#subscribeNestAPI(uuid).catch((error) => { this?.log?.debug?.( 'Unable to start Nest API subscribe for connection "%s": %s', connection?.name, typeof error?.message === 'string' ? error.message : String(error), ); }); this.#observeGoogleAPI(uuid).catch((error) => { this?.log?.debug?.( 'Unable to start Google API observe for connection "%s": %s', connection?.name, typeof error?.message === 'string' ? error.message : String(error), ); }); } }, }); } async #subscribeNestAPI(uuid, firstRun = true, fullRead = true) { let connection = this.#connections?.get(uuid); let subscribeFailed = false; if ( typeof connection !== 'object' || connection === null || connection.authorised !== true || this.config?.options?.useNestAPI !== true ) { // Not a valid connection object and/or we're not authorised return; } // By default, setup for a full data read from the Nest API let subscribeJSONData = undefined; if (firstRun !== false || fullRead !== false) { this?.log?.debug?.('Starting Nest API subscribe for connection "%s"', connection.name); subscribeJSONData = { known_bucket_types: NEST_API_BUCKETS, known_bucket_versions: [] }; } // We have data stored from this Nest API, so setup read using known object // We exclude any data source other than from Nest API and also any injected data if (firstRun === false || fullRead === false) { subscribeJSONData = { objects: [] }; subscribeJSONData.objects.push( ...Object.entries(this.#rawData) // eslint-disable-next-line no-unused-vars .filter(([key, value]) => value.source === DATA_SOURCE.NEST && value.connection === uuid && value?.injected !== true) .map(([key, value]) => ({ object_key: key, object_revision: value.object_revision, object_timestamp: value.object_timestamp, })), ); } fetchWrapper( 'post', subscribeJSONData?.objects !== undefined ? new URL('/v5/subscribe', connection.transport_url).href : new URL('/api/0.1/user/' + connection.userID + '/app_launch', 'https://' + connection.restAPIHost).href, { headers: { Referer: 'https://' + connection.referer, Origin: 'https://' + connection.referer, Authorization: 'Basic ' + connection.token, Connection: 'keep-alive', 'User-Agent': USER_AGENT, 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'same-origin', 'X-nl-protocol-version': 1, 'Content-Type': 'application/json', }, retry: 3, }, subscribeJSONData, ) .then((response) => response.json()) .then(async (data) => { let changedData = new Map(); // Map of objectKey to { fields: Set(...) } let objects = []; if (Array.isArray(data?.updated_buckets) === true) { // Full data read response objects = data.updated_buckets; } if (Array.isArray(data?.objects) === true) { // Incremental subscribe update response objects = data.objects; } // Process the data we received fullRead = false; // Reset full refresh flag unless triggered below for (let object of objects) { let objectKey = object?.object_key; let incomingValue = typeof object?.value === 'object' && object.value !== null ? { ...object.value } : {}; let existingEntry = this.#rawData?.[objectKey]; let existingValue = typeof existingEntry?.value === 'object' && existingEntry.value !== null ? existingEntry.value : {}; let changedFields = new Set(); if ((objectKey?.trim?.() ?? '') === '') { continue; } // Detect changed top-level raw fields using shallow comparison. // This is intentional: // - nested objects/arrays are treated as changed if their reference differs // - avoids expensive deep comparison during frequent Nest API updates // - HomeKitDevice performs a deeper comparison later on the final merged device data // // NOTE: This may over-report changes for complex values, but that is acceptable here // because this change set is only used to guide downstream processing. Object.keys(incomingValue).forEach((field) => { if (existingValue[field] !== incomingValue[field]) { changedFields.add(field); } }); if (objectKey.startsWith('structure.') === true) { // Add weather data based on the structure location details let weatherData = await this.#getLocationWeather(uuid, objectKey, incomingValue.postal_code, incomingValue.country_code); if (weatherData !== undefined) { incomingValue.weather = weatherData; changedFields.add('weather'); } // Detect removed child objects from the swarm list and clean up local cache if (typeof existingValue?.swarm === 'object' && Array.isArray(incomingValue?.swarm) === true) { let newSwarmSet = new Set(incomingValue.swarm); existingValue.swarm.forEach((childObjectKey) => { if (newSwarmSet.has(childObjectKey) === false) { delete this.#rawData[childObjectKey]; } }); } // Store the internal Nest structure uuid if matched to a configured home Object.assign( this.config?.homes?.find((home) => home?.name?.trim?.().toUpperCase() === incomingValue?.name?.trim?.().toUpperCase()) || {}, { nest_home_uuid: objectKey }, ); } if (objectKey.startsWith('quartz.') === true) { // Retrieve additional camera/doorbell properties let properties = await this.#getCameraProperties(uuid, objectKey); incomingValue.properties = typeof properties === 'object' && properties.constructor === Object ? properties : typeof existingValue?.properties === 'object' && existingValue.properties.constructor === Object ? existingValue.properties : {}; changedFields.add('properties'); } if (objectKey.startsWith('buckets.') === true) { if ( typeof existingEntry === 'object' && Array.isArray(existingValue?.buckets) === true && Array.isArray(incomingValue?.buckets) === true ) { // Compare previous vs incoming buckets list to detect topology changes let newBucketsSet = new Set(incomingValue.buckets); // If an existing object is missing from the new list, trigger a full refresh existingValue.buckets.forEach((childObjectKey) => { if (newBucketsSet.has(childObjectKey) === false) { fullRead = true; } }); // Detect removed objects and clean up local state existingValue.buckets.forEach((childObjectKey) => { if (newBucketsSet.has(childObjectKey) === false) { // Object existed previously but is no longer referenced, so treat as removed if ( childObjectKey.startsWith('structure.') === true || childObjectKey.startsWith('device.') === true || childObjectKey.startsWith('kryptonite.') === true || childObjectKey.startsWith('topaz.') === true || childObjectKey.startsWith('quartz.') === true ) { let serialNumber = this.#rawData?.[childObjectKey]?.value?.serial_number; let trackedDevice = this.#trackedDevices.get(serialNumber); if (trackedDevice !== undefined) { // Send removed notice onto HomeKit device for it to process HomeKitDevice.message(trackedDevice.uuid, HomeKitDevice.REMOVE, {}); // Finally, remove from tracked devices this.#trackedDevices.delete(serialNumber); } } delete this.#rawData[childObjectKey]; } }); } } // Only record this object if at least one field changed if (changedFields.size !== 0) { changedData.set(objectKey, { fields: changedFields }); } // Merge incoming data into the raw data store this.#rawData[objectKey] = { object_revision: object.object_revision, object_timestamp: object.object_timestamp, connection: uuid, source: DATA_SOURCE.NEST, value: { ...existingValue, ...incomingValue, }, }; } await this.#processData(uuid, changedData); connection.subscribeRetryDelay = undefined; }) .catch((error) => { subscribeFailed = true; // Attempt to extract HTTP status code from error cause or error object let statusCode = error?.code !== undefined && error?.code !== null ? error.code : error?.status !== undefined && error?.status !== null ? error.status : undefined; // If we get a 401 Unauthorized or 403 Forbidden, wake the connection // lifecycle so it can re-authorise and reschedule itself. if ((statusCode === 401 || statusCode === 403) && connection.authorised === true) { this?.log?.debug?.('Connection "%s" is no longer authorised with the Nest API, will attempt to reconnect', connection.name); this.#connections?.markUnauthorised?.(uuid, 'nest-api-' + statusCode); return; } // Log unexpected errors (excluding timeouts) for debugging if ( error?.cause === undefined || (error.cause?.message?.toUpperCase?.()?.includes('TIMEOUT') === false && error.cause?.code?.toUpperCase?.()?.includes('TIMEOUT') === false) ) { this?.log?.debug?.( 'Nest API had an error performing subscription with connection "%s". Error was "%s"', connection.name, typeof error?.message === 'string' ? error.message : String(error), ); } }) .finally(() => { // Only continue the subscription loop if this exact connection is still active and authorised. if (this.#connections?.get(uuid) === connection && connection.authorised === true) { let subscribeDelay = API_STREAM_LOOP_INTERVAL; if (subscribeFailed === true) { subscribeDelay = Number.isFinite(connection.subscribeRetryDelay) === true && connection.subscribeRetryDelay > 0 ? Math.min(connection.subscribeRetryDelay * 2, API_STREAM_RETRY_MAX) : API_STREAM_RETRY_INITIAL; connection.subscribeRetryDelay = subscribeDelay; } clearTimeout(connection.subscribeTimer); connection.subscribeTimer = setTimeout(() => this.#subscribeNestAPI(uuid, false, fullRead), subscribeDelay); } }); } async #observeGoogleAPI(uuid) { let connection = this.#connections?.get(uuid); let observeFailed = false; if ( typeof connection !== 'object' || connection === null || connection.authorised !== true || connection.grpcTransport === undefined || this.config?.options?.useGoogleAPI !== true ) { // Not a valid connection object and/or we're not authorised return; } // Dynamically build the 'observe' post body data from cached protobuf message types let observeTraitsList = getProtoTypes(path.join(__dirname, 'protobuf/root.proto'), this.log) .filter((type) => { return ( (connection.type === ACCOUNT_TYPE.NEST && type.fullName.startsWith('.nest.trait.product.camera') === false && type.fullName.startsWith('.nest.trait.product.doorbell') === false && (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true)) || (connection.type === ACCOUNT_TYPE.GOOGLE && (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true || type.fullName.startsWith('.google.trait.product.camera') === true)) ); }) .map((type) => ({ traitType: type.fullName.replace(/^\.*|\.*$/g, ''), })); // Dedupe the observe traits list since there can be some overlap in the traits // due to the dynamic nature of the protobuf loading and trait type matching observeTraitsList = [...new Map(observeTraitsList.map((entry) => [entry.traitType, entry])).values()]; // If protobuf support is unavailable or no observable traits were found, // do not start the observe loop. Retrying every second would only create // noise until the underlying protobuf load problem is fixed. if (observeTraitsList.length === 0) { this?.log?.warn?.( 'Google API observe cannot start for connection "%s" because no observable protobuf traits were loaded', connection.name, ); return; } connection.grpcTransport .observe( 'nestlabs.gateway.v2.', 'GatewayService', 'Observe', { stateTypes: ['CONFIRMED', 'ACCEPTED'], traitTypeParams: observeTraitsList }, async (message) => { let observeResponses = Array.isArray(message?.observeResponse) === true ? message.observeResponse : [message].filter(Boolean); let changedData = new Map(); // Map of resourceId to { fields: Set(...) } for processing after the loop // We'll use the resource status message to look for structure and/or device removals // We could also check for structure and/or device additions here, but we'll want to be flagged // that a device is 'ready' for use before we add in. This data is populated in the trait data for (let observeResponse of observeResponses) { // resourceMetas if (Array.isArray(observeResponse?.resourceMetas) === true) { for (let resource of observeResponse.resourceMetas) { if ( resource.status === 'REMOVED' && (resource.resourceId.startsWith('STRUCTURE_') === true || resource.resourceId.startsWith('DEVICE_') === true) ) { // We have the removal of a 'home' and/or device // Tidy up tracked devices since this one is removed let serialNumber = this.#rawData?.[resource.resourceId]?.value?.device_identity?.serialNumber; let trackedDevice = this.#trackedDevices.get(serialNumber); if (trackedDevice !== undefined) { // Send removed notice onto HomeKit device for it to process HomeKitDevice.message(trackedDevice.uuid, HomeKitDevice.REMOVE, {}); // Finally, remove from tracked devices this.#trackedDevices.delete(serialNumber); } delete this.#rawData[resource.resourceId]; } } } // traitStates if (Array.isArray(observeResponse?.traitStates) === true) { // Tidy up our received trait states. This ensures we only have one status for the trait in the data we process // We'll favour a trait with accepted status over the same with confirmed status let traits = observeResponse.traitStates; let acceptedKeys = new Set( traits .filter((trait) => trait.stateTypes.includes('ACCEPTED') === true) .map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel), ); observeResponse.traitStates = [ ...traits.filter((trait) => acceptedKeys.has(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false), ...traits.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true), ]; for (let trait of observeResponse.traitStates) { let resourceId = trait.traitId.resourceId; let traitLabel = trait.traitId.traitLabel; let patchValues = trait?.patch?.values ?? {}; let changedEntry = changedData.get(resourceId); // Mapped changed data for processing after the loop if (changedEntry === undefined) { changedEntry = { fields: new Set() }; changedData.set(resourceId, changedEntry); } changedEntry.fields.add(traitLabel); // Create or update trait entry and assign latest patch values this.#rawData[resourceId] = { connection: uuid, source: DATA_SOURCE.GOOGLE, value: { ...this.#rawData?.[resourceId]?.value, [traitLabel]: patchValues, }, }; // Remove trait type metadata — we don't need to store it delete this.#rawData[resourceId]?.value?.[traitLabel]?.['@type']; // If we have structure location details and associated geo-location details, get the weather data for the location // We'll store this in the object key/value as per Nest API if ( resourceId.startsWith('STRUCTURE_') === true && traitLabel === 'structure_location' && (patchValues?.postalCode?.value?.trim?.() ?? '') !== '' && (patchValues?.countryCode?.value?.trim?.() ?? '') !== '' ) { let weatherData = await this.#getLocationWeather( uuid, resourceId, patchValues.postalCode.value, patchValues.countryCode.value, ); if (weatherData !== undefined && typeof this.#rawData?.[resourceId]?.value === 'object') { this.#rawData[resourceId].value.weather = { ...weatherData }; changedEntry.fields.add('weather'); } } // Store the internal Nest and Google structure uuids if matched to a defined home array entry if ( resourceId.startsWith('STRUCTURE_') === true && traitLabel === 'structure_info' && (patchValues?.name?.trim?.() ?? '') !== '' ) { Object.assign( this.config?.homes?.find((home) => home?.name?.trim?.().toUpperCase() === patchValues.name?.trim?.().toUpperCase()) || {}, { nest_home_uuid: patchValues.rtsStructureId, google_home_uuid: resourceId }, ); } // We have an update for a camera live image trait // so we'll trigger any waiting snapshot requests to process this new image data if (traitLabel === 'upload_live_image' && connection.snapshotWaiters instanceof Map) { let waiter = connection.snapshotWaiters.get(resourceId); connection.snapshotWaiters.delete(resourceId); if (typeof waiter === 'function') { waiter(); } } } } } await this.#processData(uuid, changedData); connection.observeRetryDelay = undefined; }, ) .catch((error) => { observeFailed = true; this?.log?.debug?.( 'Google API observe failed for connection "%s": %s', connection.name, typeof error?.message === 'string' ? error.message : String(error), ); }) .finally(() => { // Only continue the observe loop if this exact connection is still active and authorised. if (this.#connections?.get(uuid) === connection && connection.authorised === true) { let observeDelay = API_STREAM_LOOP_INTERVAL; if (observeFailed === true) { observeDelay = Number.isFinite(connection.observeRetryDelay) === true && connection.observeRetryDelay > 0 ? Math.min(connection.observeRetryDelay * 2, API_STREAM_RETRY_MAX) : API_STREAM_RETRY_INITIAL; connection.observeRetryDelay = observeDelay; } clearTimeout(connection.observeTimer); connection.observeTimer = setTimeout(() => this.#observeGoogleAPI(uuid), observeDelay); } }); } async #processData(uuid, changedData = undefined) { let connection = this.#connections?.get(uuid); const dumpSupportData = (source, changedData = undefined) => { let sourceInfo = source === DATA_SOURCE.GOOGLE ? { name: 'Google API' } : source === DATA_SOURCE.NEST ? { name: 'Nest API' } : undefined; let didLogAny = false; let isDelta = changedData instanceof Map === true && changedData.size !== 0; // Validate we should attempt a support dump for this connection/source if ( this?.config?.options?.supportDump !== true || typeof uuid !== 'string' || uuid.trim() === '' || typeof connection !== 'object' || connection === null || typeof sourceInfo !== 'object' ) { return; } // Iterate raw data directly and decide at object/field level what to output Object.entries(this.#rawData).forEach(([objectKey, data]) => { let changedFields = isDelta === true ? changedData.get(objectKey)?.fields : undefined; let loggedObject = false; // Only process objects for this source/connection with valid value payload if ( data?.source !== source || data?.connection !== uuid || typeof data?.value !== 'object' || data.value === null || Object.keys(data.value).length === 0 ) { return; } // In delta mode, skip objects that were not part of this update if (isDelta === true && changedFields instanceof Set !== true) { return; } Object.entries(data.value).forEach(([key, value]) => { // In delta mode, only output fields that were part of this update if (isDelta === true && changedFields.has(key) !== true) { return; } // Lazily open object and print header only when we actually output something if (loggedObject === false) { if (didLogAny === false) { this?.log?.info?.( '%s support dump for %s data will be logged below for troubleshooting purposes.', isDelta === true ? 'Changed' : 'Full', sourceInfo.name, ); } this?.log?.info?.('{'); this?.log?.info?.(' "%s": {', objectKey); loggedObject = true; didLogAny = true; } // Pretty-print nested objects if (typeof value === 'object' && value !== null) { this?.log?.info?.(' %s:', key); String(JSON.stringify(value, null, 2)) .split('\n') .forEach((line) => { this?.log?.info?.(' %s', line); }); return; } // Primitive values this?.log?.info?.(' %s: %j', key, value); }); // Close object if we logged any fields if (loggedObject === true) { this?.log?.info?.(' }'); this?.log?.info?.('}'); } }); // Footer only if something was actually logged if (didLogAny === true) { this?.log?.info?.('End of support dump for %s data.', sourceInfo.name); } }; // First run logs a full baseline per source/connection. // Later runs log only the changed fields for changed objects in this cycle. dumpSupportData(DATA_SOURCE.NEST, changedData); dumpSupportData(DATA_SOURCE.GOOGLE, changedData); // Process the raw data through each of the device modules to get the latest device details and states for (let [deviceType, deviceModule] of this.#deviceModules) { if (typeof deviceModule?.processRawData === 'function') { let devices = {}; try { devices = deviceModule.processRawData(this.log, this.#rawData, this.config, deviceType, changedData); } catch (error) { this?.log?.warn?.('%s module failed to process data. Error was "%s"', deviceType, String(error)); } if (typeof devices === 'object' && devices !== null) { for (let [serialNumber, result] of Object.entries(devices)) { let deviceData = result?.data; let isFull = result?.full === true; let trackedDevice = this.#trackedDevices.get(serialNumber); if (deviceData === null || typeof deviceData !== 'object' || deviceData?.constructor !== Object) { continue; } if (trackedDevice === undefined && isFull === true && deviceData?.excluded === true) { // We haven't tracked this device before (ie: should be a new one) and but its excluded let homeName = this.#rawData?.[deviceData.nest_google_home_uuid]?.value?.name || this.#rawData?.[deviceData.nest_google_home_uuid]?.value?.structure_info?.name; if (deviceType !== DEVICE_TYPE.WEATHER) { this?.log?.warn?.( 'Device "%s"%s is ignored due to it being marked as excluded', deviceData.description, (homeName?.trim?.() ?? '') !== '' ? ' in "' + homeName + '"' : '', ); } // Track this device even though its excluded this.#trackedDevices.set(serialNumber, { uuid: HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, this.api, serialNumber), nest_google_device_uuid: deviceData.nest_google_device_uuid, type: deviceModule.class.TYPE, // Store type of device source: undefined, // gets filled out later timers: undefined, exclude: true, }); trackedDevice = this.#trackedDevices.get(serialNumber); // If the device is now marked as excluded and present in accessory cache // Then we'll unregister it from the Homebridge platform let accessory = this.cachedAccessories.find((accessory) => accessory?.UUID === trackedDevice.uuid); if (accessory !== undefined && typeof accessory === 'object') { try { this.api.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [accessory]); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } } } if (trackedDevice === undefined && isFull === true && deviceData?.excluded === false) { // We haven't tracked this device before (ie: should be a new one) and its not excluded // so create the required HomeKit accessories based upon the device data if ( typeof deviceModule?.class === 'function' && (deviceModule.class.TYPE?.trim?.() ?? '') !== '' && (deviceModule.class.VERSION?.trim?.() ?? '') !== '' ) { // We have found a device class for this device type, so we can create the device let accessoryName = (deviceData.manufacturer?.trim() || 'Nest') + ' ' + deviceModule.class.TYPE.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[^a-zA-Z0-9 ]+/g, ' ') .toLowerCase() .replace(/\b\w/g, (character) => character.toUpperCase()); // For camera type devices, inject camera API auth credentials before initial setup // so streaming backends have auth details during their first construction/update pass. if ( deviceModule.class.TYPE === DEVICE_TYPE.CAMERA || deviceModule.class.TYPE === DEVICE_TYPE.DOORBELL || deviceModule.class.TYPE === DEVICE_TYPE.FLOODLIGHT ) { deviceData.apiAccess = this.#connections?.get( this.#rawData?.[deviceData?.nest_google_device_uuid]?.connection, )?.cameraAuth; } let tempDevice = new deviceModule.class(this.cachedAccessories, this.api, deviceData); await tempDevice.add(accessoryName, getDeviceHKCategory(deviceModule.class.TYPE), deviceData?.eveHistory === true); // Register per-device set/get handlers HomeKitDevice.message(tempDevice.uuid, HomeKitDevice.SET, async (values) => { await this.#set(this.#rawData?.[values?.uuid]?.connection, values?.uuid, values); }); HomeKitDevice.message(tempDevice.uuid, HomeKitDevice.GET, async (values) => { return await this.#get(this.#rawData?.[values?.uuid]?.connection, values?.uuid, values); }); // Track this device once created this.#trackedDevices.set(serialNumber, { uuid: tempDevice.uuid, nest_google_device_uuid: deviceData.nest_google_device_uuid, type: deviceModule.class.TYPE, // Store type of device source: undefined, // gets filled out later timers: {}, exclude: false, }); trackedDevice = this.#trackedDevices.get(serialNumber); } } // Ignore partial payloads for devices we have not yet created/tracked if (trackedDevice === undefined) { continue; } // Finally, if device is not excluded, send updated data to device for it to process if (trackedDevice?.exclude === false) { let resourceId = deviceData?.nest_google_device_uuid; let resourceData = this.#rawData?.[resourceId]; let newSource = resourceData?.source; if (newSource !== undefined && newSource !== trackedDevice.source) { // Data source for this device has changed. // Allow initial source assignment and Nest -> Google upgrades. // Camera, doorbell, and floodlight devices may also move back // from Google -> Nest because streaming can depend on Nest data. if ( trackedDevice.source === undefined || (trackedDevice.source === DATA_SOURCE.NEST && newSource === DATA_SOURCE.GOOGLE) || ((deviceModule.class.TYPE === DEVICE_TYPE.CAMERA || deviceModule.class.TYPE === DEVICE_TYPE.DOORBELL || deviceModule.class.TYPE === DEVICE_TYPE.FLOODLIGHT) && trackedDevice.source === DATA_SOURCE.GOOGLE) ) { this?.log?.debug?.( 'Using %s API as data source for "%s" from connection "%s"', newSource, deviceData.description, this.#connections.get(resourceData.connection)?.name, ); trackedDevice.source = newSource; trackedDevice.nest_google_device_uuid = resourceId; } } // Send updated data onto HomeKit device for it to process HomeKitDevice.message(trackedDevice.uuid, HomeKitDevice.UPDATE, deviceData); } } } } } } async #set(uuid, nest_google_device_uuid, values) { let connection = this.#connections?.get(uuid); if ( typeof values !== 'object' || values === null || typeof this.#rawData?.[nest_google_device_uuid] !== 'object' || typeof connection !== 'object' || connection === null || connection.authorised !== true ) { return; } for (let [key, value] of Object.entries(values)) { try { if (key === 'uuid') { // We don't do anything with the key containing the uuid continue; } if (this.#rawData?.[nest_google_device_uuid]?.source === DATA_SOURCE.GOOGLE && connection.grpcTransport !== undefined) { let updatedTraits = []; let commandTraits = []; let updateElement = { traitRequest: { resourceId: nest_google_device_uuid, traitLabel: '', requestId: crypto.randomUUID(), }, state: { type_url: '', value: {}, }, }; let commandElement = { resourceRequest: { resourceId: nest_google_device_uuid, requestId: crypto.randomUUID(), }, resourceCommands: [], }; // Helper function to set the update trait details based on the key/value passed in. // with optional explicit trait value and updates to merge in let setUpdateTrait = (traitLabel, typeURL, traitValue = undefined, updates = undefined) => { updateElement.traitRequest.traitLabel = traitLabel; updateElement.state.type_url = typeURL; // If no explicit value passed, infer from rawData if (traitValue === undefined) { traitValue = this.#rawData?.[nest_google_device_uuid]?.value?.[traitLabel]; } updateElement.state.value = typeof traitValue === 'object' && traitValue !== null ? structuredClone(traitValue) : {}; // Optionally merge in simple top-level updates if (typeof updates === 'object' && updates !== null) { Object.assign(updateElement.state.value, updates); } }; // Helper function to set the command trait details based on the key/value passed in (optional explicit resourceId override) let setCommandTrait = (traitLabel, typeURL, commandValue, resourceId = nest_google_device_uuid) => { commandElement.resourceRequest.resourceId = resourceId; commandElement.resourceCommands = [ { traitLabel, command: { type_url: typeURL, value: commandValue, }, }, ]; }; if ( (key === 'hvac_mode' && ['OFF', 'COOL', 'HEAT', 'RANGE'].includes(value?.toUpperCase?.())) || (['target_temperature', 'target_temperature_low', 'target_temperature_high'].includes(key) === true && this.#rawData?.[nest_google_device_uuid]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' && Number.isFinite(Number(value)) === true) ) { // Set either the 'mode' and/or non-eco temperatures on the target thermostat setUpdateTrait('target_temperature_settings', 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait'); if ( (key === 'target_temperature_low' || key === 'target_temperature') && (updateElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT' || updateElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { // Changing heating target temperature updateElement.state.value.targetTemperature.heatingTarget = { value: Number(value) }; } if ( (key === 'target_temperature_high' || key === 'target_temperature') && (updateElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL' || updateElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { // Changing cooling target temperature updateElement.state.value.targetTemperature.coolingTarget = { value: Number(value) }; } if (key === 'hvac_mode' && value.toUpperCase() !== 'OFF') { updateElement.state.value.targetTemperature.setpointType = 'SET_POINT_TYPE_' + value.toUpperCase(); updateElement.state.value.enabled = { value: true }; } if (key === 'hvac_mode' && value.toUpperCase() === 'OFF') { updateElement.state.value.enabled = { value: false }; } // Tag 'who' is doing the temperature/mode change. We are ie: the device :-) updateElement.state.value.targetTemperature.currentActorInfo = { method: 'HVAC_ACTOR_METHOD_IOS', originator: { resourceId: nest_google_device_uuid }, timeOfAction: { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 }, }; } if ( ['target_temperature', 'target_temperature_low', 'target_temperature_high'].includes(key) === true && this.#rawData?.[nest_google_device_uuid]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' && Number.isFinite(Number(value)) === true ) { // Set eco mode temperatures on the target thermostat setUpdateTrait('eco_mode_settings', 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait'); updateElement.state.value.ecoTemperatureHeat.value.value = updateElement.state.value.ecoTemperatureHeat.enabled === true && updateElement.state.value.ecoTemperatureCool.enabled === false ? Number(value) : updateElement.state.value.ecoTemperatureHeat.value.value; updateElement.state.value.ecoTemperatureCool.value.value = updateElement.state.value.ecoTemperatureHeat.enabled === false && updateElement.state.value.ecoTemperatureCool.enabled === true ? Number(value) : updateElement.state.value.ecoTemperatureCool.value.value; updateElement.state.value.ecoTemperatureHeat.value.value = updateElement.state.value.ecoTemperatureHeat.enabled === true && updateElement.state.value.ecoTemperatureCool.enabled === true && key === 'target_temperature_low' ? Number(value) : updateElement.state.value.ecoTemperatureHeat.value.value; updateElement.state.value.ecoTemperatureCool.value.value = updateElement.state.value.ecoTemperatureHeat.enabled === true && updateElement.state.value.ecoTemperatureCool.enabled === true && key === 'target_temperature_high' ? Number(value) : updateElement.state.value.ecoTemperatureCool.value.value; } if (key === 'temperature_scale' && (value?.toUpperCase?.() === 'C' || value?.toUpperCase?.() === 'F')) { // Set the temperature scale on the target thermostat setUpdateTrait('display_settings', 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait', undefined, { temperatureScale: value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C', }); } if (key === 'temperature_lock'