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