UNPKG

homebridge-nest-accfactory

Version:

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

1,052 lines (964 loc) 172 kB
// Nest System communications // Part of homebridge-nest-accfactory // // Code version 2025.06.15 // Mark Hulskamp 'use strict'; // Define external module requirements import protobuf from 'protobufjs'; // Define nodejs module requirements import EventEmitter from 'node:events'; import { Buffer } from 'node:buffer'; import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; // Import our modules import HomeKitDevice from './HomeKitDevice.js'; import { DEVICE_TYPE, loadDeviceModules, getDeviceHKCategory } from './devices.js'; import { ACCOUNT_TYPE, processConfig, buildConnections } from './config.js'; // Define constants const CAMERA_ALERT_POLLING = 2000; // Camera alerts polling timer const CAMERA_ZONE_POLLING = 30000; // Camera zones changes polling timer const WEATHER_POLLING = 300000; // Weather data polling timer const NEST_API_TIMEOUT = 10000; // Nest API timeout const USER_AGENT = 'Nest/5.78.0 (iOScom.nestlabs.jasper.release) os=18.0'; // User Agent string const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname const DATASOURCE = { NEST_API: 'Nest', // From the Nest API PROTOBUF_API: 'Protobuf', // From the Protobuf API }; // 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; // Object of confirmed connections #rawData = {}; // Cached copy of data from both Nest and Protobuf APIs #eventEmitter = new EventEmitter(); // Used for object messaging from this platform #protobufRoot = null; // Protobuf loaded protos #trackedDevices = {}; // Object of devices we've created. used to track data source type, comms uuid. key'd by serial # #deviceModules = undefined; // No loaded device support modules to start constructor(log, config, api, eventEmitter) { // If no explicit event emitter was passed, and the api is an EventEmitter (e.g., in Homebridge), // we'll treat it as the source for lifecycle messages like didFinishLaunching/shutdown in this constructor if (api instanceof EventEmitter && eventEmitter === undefined) { eventEmitter = api; } this.log = log; this.api = api; // Perform validation on the configuration passed into us and set defaults if not present this.config = processConfig(config, this.log); this.#connections = buildConnections(this.config); // Check for valid connections, either a Nest and/or Google one specified. Otherwise, return back. if (Object.keys(this.#connections).length === 0) { this?.log?.error?.('No connections have been specified in the JSON configuration. Please review'); return; } eventEmitter?.on?.('didFinishLaunching', async () => { // We got notified that Homebridge (or Docker) has finished loading // Load device support modules from the plugins folder if not already done this.#deviceModules = await loadDeviceModules(this.log, 'plugins'); // Start reconnect loop per connection with backoff for failed tries // This also initiates both Nest and Protobuf subscribes for (const uuid of Object.keys(this.#connections)) { let reconnectDelay = 15000; const reconnectLoop = async () => { if (this.#connections?.[uuid]?.authorised === false) { try { await this.#connect(uuid); this.#subscribeNest(uuid, true); this.#subscribeProtobuf(uuid, true); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } reconnectDelay = this.#connections?.[uuid]?.authorised === true ? 15000 : Math.min(reconnectDelay * 2, 60000); } else { reconnectDelay = 15000; } setTimeout(reconnectLoop, reconnectDelay); }; reconnectLoop(); } }); eventEmitter?.on?.('shutdown', async () => { // We got notified that Homebridge is shutting down // Perform cleanup of internal state this.#eventEmitter?.removeAllListeners(); Object.values(this.#trackedDevices).forEach((device) => { Object.values(device?.timers || {}).forEach((timer) => clearInterval(timer)); }); this.#trackedDevices = {}; this.#rawData = {}; this.#protobufRoot = null; this.#eventEmitter = undefined; }); // Setup event listeners for set/get calls from devices if not already done so this.#eventEmitter.addListener(HomeKitDevice.SET, (uuid, values) => { this.#set(values); }); this.#eventEmitter.addListener(HomeKitDevice.GET, async (uuid, values) => { let results = await this.#get(values); // Send the results back to the device via a special event (only if still active) this.#eventEmitter?.emit?.(HomeKitDevice.GET + '->' + uuid, results); }); } 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); // add the restored accessory to the accessories cache, so we can track if it has already been registered this.cachedAccessories.push(accessory); } async #connect(uuid) { if (typeof this.#connections?.[uuid] === 'object') { this?.log?.info?.( 'Performing authorisation for connection "%s" %s', this.#connections[uuid].name, this.#connections[uuid].fieldTest === true ? 'using field test endpoints' : '', ); if (this.#connections[uuid].type === ACCOUNT_TYPE.GOOGLE) { // Google cookie method as refresh token method no longer supported by Google since October 2022 // Instructions from homebridge_nest or homebridge_nest_cam to obtain this this?.log?.debug?.('Performing authorisation using Google account for connection uuid "%s"', uuid); await fetchWrapper('get', this.#connections[uuid].issuetoken, { headers: { referer: 'https://accounts.google.com/o/oauth2/iframe', 'User-Agent': USER_AGENT, cookie: this.#connections[uuid].cookie, 'Sec-Fetch-Mode': 'cors', 'X-Requested-With': 'XmlHttpRequest', }, }) .then((response) => response.json()) .then(async (data) => { let googleOAuth2Token = data.access_token; await fetchWrapper( 'post', 'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt', { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, Authorization: data.token_type + ' ' + data.access_token, 'Content-Type': 'application/x-www-form-urlencoded', }, }, 'embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=' + data.access_token + '&policy_id=authproxy-oauth-policy', ) .then((response) => response.json()) .then(async (data) => { let googleToken = data.jwt; let tokenExpire = Math.floor(new Date(data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr await fetchWrapper('get', 'https://' + this.#connections[uuid].restAPIHost + '/session', { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, Authorization: 'Basic ' + googleToken, }, }) .then((response) => response.json()) .then((data) => { // Store successful connection details this.#connections[uuid].authorised = true; this.#connections[uuid].userID = data.userid; this.#connections[uuid].transport_url = data.urls.transport_url; this.#connections[uuid].weather_url = data.urls.weather_url; this.#connections[uuid].token = googleToken; this.#connections[uuid].cameraAPI = { key: 'Authorization', value: 'Basic ', // NOTE: extra space required token: googleToken, oauth2: googleOAuth2Token, fieldTest: this.#connections[uuid]?.fieldTest === true, }; // Set timeout for token expiry refresh clearTimeout(this.#connections[uuid].timer); this.#connections[uuid].timer = setTimeout( () => { this?.log?.info?.('Performing periodic token refresh for connection "%s"', this.#connections[uuid].name); this.#connect(uuid); }, (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000, ); // Refresh just before token expiry this?.log?.success?.('Successfully authorised connection "%s"', this.#connections[uuid].name); }); }); }) // eslint-disable-next-line no-unused-vars .catch((error) => { // The token we used to obtained a Nest session failed, so overall authorisation failed this.#connections[uuid].authorised = false; this?.log?.debug?.('Failed to connect using credential details for connection uuid "%s"', uuid); this?.log?.error?.('Authorisation failed on connection "%s"', this.#connections[uuid].name); }); } if (this.#connections[uuid].type === ACCOUNT_TYPE.NEST) { // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later this?.log?.debug?.('Performing authorisation using Nest account for connection uuid "%s"', uuid); await fetchWrapper( 'post', 'https://webapi.' + this.#connections[uuid].cameraAPIHost + '/api/v1/login.login_nest', { withCredentials: true, headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, 'Content-Type': 'application/x-www-form-urlencoded', }, }, Buffer.from('access_token=' + this.#connections[uuid].access_token, 'utf8'), ) .then((response) => response.json()) .then(async (data) => { if (data?.items?.[0]?.session_token === undefined) { throw new Error('No Nest session token was obtained'); } let nestToken = data.items[0].session_token; await fetchWrapper('get', 'https://' + this.#connections[uuid].restAPIHost + '/session', { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, Authorization: 'Basic ' + this.#connections[uuid].access_token, }, }) .then((response) => response.json()) .then((data) => { // Store successful connection details this.#connections[uuid].authorised = true; this.#connections[uuid].userID = data.userid; this.#connections[uuid].transport_url = data.urls.transport_url; this.#connections[uuid].weather_url = data.urls.weather_url; this.#connections[uuid].token = this.#connections[uuid].access_token; this.#connections[uuid].cameraAPI = { key: 'cookie', value: this.#connections[uuid].fieldTest === true ? 'website_ft=' : 'website_2=', token: nestToken, fieldTest: this.#connections[uuid]?.fieldTest === true, }; // Set timeout for token expiry refresh clearTimeout(this.#connections[uuid].timer); this.#connections[uuid].timer = setTimeout( () => { this?.log?.info?.('Performing periodic token refresh for connection "%s"', this.#connections[uuid].name); this.#connect(uuid); }, 1000 * 3600 * 24, ); // Refresh token every 24hrs this?.log?.success?.('Successfully authorised connection "%s"', this.#connections[uuid].name); }); }) // eslint-disable-next-line no-unused-vars .catch((error) => { // The token we used to obtained a Nest session failed, so overall authorisation failed this.#connections[uuid].authorised = false; this?.log?.debug?.('Failed to connect using credential details for connection uuid "%s"', uuid); this?.log?.error?.('Authorisation failed on connection "%s"', this.#connections[uuid].name); }); } } } async #subscribeNest(uuid, fullRefresh) { if ( typeof this.#connections?.[uuid] !== 'object' || this.#connections?.[uuid]?.authorised === false || this.config?.options?.useNestAPI === false ) { // Not a valid connection object and/or we're not authorised return; } const REQUIREDBUCKETS = [ 'buckets', 'structure', 'where', 'safety', 'device', 'shared', 'track', 'link', 'rcs_settings', 'schedule', 'kryptonite', 'topaz', 'widget_track', 'quartz', 'occupancy', ]; // By default, setup for a full data read from the Nest API let subscribeURL = 'https://' + this.#connections[uuid].restAPIHost + '/api/0.1/user/' + this.#connections[uuid].userID + '/app_launch'; let subscribeJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] }; if (fullRefresh === false) { // We have data stored from this Nest API, so setup read using known object subscribeURL = this.#connections[uuid].transport_url + '/v6/subscribe'; subscribeJSONData = { objects: [] }; Object.entries(this.#rawData) // eslint-disable-next-line no-unused-vars .filter(([object_key, object]) => object.source === DATASOURCE.NEST_API && object.connection === uuid) .forEach(([object_key, object]) => { subscribeJSONData.objects.push({ object_key: object_key, object_revision: object.object_revision, object_timestamp: object.object_timestamp, }); }); } if (fullRefresh === true) { this?.log?.debug?.('Starting Nest API subscribe for connection uuid "%s"', uuid); } fetchWrapper( 'post', subscribeURL, { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, Authorization: 'Basic ' + this.#connections[uuid].token, }, keepalive: true, //timeout: (5 * 60000), }, JSON.stringify(subscribeJSONData), ) .then((response) => response.json()) .then(async (data) => { if (typeof data?.updated_buckets === 'object') { // This response is full data read data = data.updated_buckets; } if (typeof data?.objects === 'object') { // This response contains subscribed data updates data = data.objects; } // Process the data we received fullRefresh = false; // Not a full data refresh required when we start again await Promise.all( data.map(async (value) => { if (value.object_key.startsWith('structure.') === true) { // Since we have a structure key, need to add in weather data for the location using latitude and longitude details if (typeof value.value?.weather !== 'object') { value.value.weather = {}; } if ( typeof this.#rawData[value.object_key] === 'object' && typeof this.#rawData[value.object_key].value?.weather === 'object' ) { value.value.weather = this.#rawData[value.object_key].value.weather; } value.value.weather = await this.#getWeather(uuid, value.object_key, value.value.latitude, value.value.longitude); // Check for changes in the swarm property. This seems indicate changes in devices if (typeof this.#rawData[value.object_key] === 'object') { this.#rawData[value.object_key].value.swarm.map((object_key) => { if (value.value.swarm.includes(object_key) === false) { // Object is present in the old swarm list, but not in the new swarm list, so we assume it has been removed // We'll remove the associated object here for future subscribe delete this.#rawData[object_key]; } }); } } if (value.object_key.startsWith('quartz.') === true) { // We have camera(s) and/or doorbell(s), so get extra details that are required value.value.properties = typeof this.#rawData[value.object_key]?.value?.properties === 'object' ? this.#rawData[value.object_key].value.properties : []; try { let response = await fetchWrapper( 'get', 'https://webapi.' + this.#connections[uuid].cameraAPIHost + '/api/cameras.get_with_properties?uuid=' + value.object_key.split('.')[1], { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, [this.#connections[uuid].cameraAPI.key]: this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token, }, timeout: NEST_API_TIMEOUT, }, ); let data = await response.json(); value.value.properties = data.items[0].properties; } catch (error) { if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) { this?.log?.debug?.( 'Nest API had error retrieving camera/doorbell details during subscribe. Error was "%s"', error?.code ?? String(error), ); } } value.value.activity_zones = typeof this.#rawData[value.object_key]?.value?.activity_zones === 'object' ? this.#rawData[value.object_key].value.activity_zones : []; try { let response = await fetchWrapper( 'get', value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, [this.#connections[uuid].cameraAPI.key]: this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token, }, timeout: NEST_API_TIMEOUT, }, ); let data = await response.json(); value.value.activity_zones = data .filter((zone) => zone?.type?.toUpperCase() === 'ACTIVITY' || zone?.type?.toUpperCase() === 'REGION') .map((zone) => ({ id: zone.id === 0 ? 1 : zone.id, name: HomeKitDevice.makeHomeKitName(zone.label), hidden: zone.hidden === true, uri: zone.nexusapi_image_uri, })); } catch (error) { if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) { this?.log?.debug?.( 'Nest API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"', error?.code ?? String(error), ); } } } if (value.object_key.startsWith('buckets.') === true) { if ( typeof this.#rawData[value.object_key] === 'object' && typeof this.#rawData[value.object_key].value?.buckets === 'object' ) { // Check for added objects value.value.buckets.map((object_key) => { if (this.#rawData[value.object_key].value.buckets.includes(object_key) === false) { // Since this is an added object to the raw Nest API structure, we need to do a full read of the data fullRefresh = true; } }); // Check for removed objects this.#rawData[value.object_key].value.buckets.map((object_key) => { if (value.value.buckets.includes(object_key) === false) { // Object is present in the old buckets list, but not in the new buckets list // so we assume it has been removed // It also could mean device(s) have been removed from Nest if ( object_key.startsWith('structure.') === true || object_key.startsWith('device.') === true || object_key.startsWith('kryptonite.') === true || object_key.startsWith('topaz.') === true || object_key.startsWith('quartz.') === true ) { // Tidy up tracked devices since this one is removed if (this.#trackedDevices[this.#rawData?.[object_key]?.value?.serial_number] !== undefined) { // Remove any active running timers we have for this device Object.values(this.#trackedDevices[this.#rawData[object_key].value.serial_number].timers).forEach((timers) => { clearInterval(timers); }); // Send removed notice onto HomeKit device for it to process this.#eventEmitter?.emit?.( this.#trackedDevices[this.#rawData[object_key].value.serial_number].uuid, HomeKitDevice.REMOVE, {}, ); // Finally, remove from tracked devices delete this.#trackedDevices[this.#rawData[object_key].value.serial_number]; } } delete this.#rawData[object_key]; } }); } } // Store or update the date in our internally saved raw Nest API data if (typeof this.#rawData[value.object_key] === 'undefined') { this.#rawData[value.object_key] = {}; this.#rawData[value.object_key].object_revision = value.object_revision; this.#rawData[value.object_key].object_timestamp = value.object_timestamp; this.#rawData[value.object_key].connection = uuid; this.#rawData[value.object_key].source = DATASOURCE.NEST_API; this.#rawData[value.object_key].value = {}; } // Finally, update our internal raw Nest API data with the new values this.#rawData[value.object_key].object_revision = value.object_revision; // Used for future subscribes this.#rawData[value.object_key].object_timestamp = value.object_timestamp; // Used for future subscribes for (const [fieldKey, fieldValue] of Object.entries(value.value)) { this.#rawData[value.object_key]['value'][fieldKey] = fieldValue; } }), ); await this.#processPostSubscribe(); }) .catch((error) => { if ( error?.cause === undefined || (typeof error.cause === 'object' && String(error.cause).toUpperCase().includes('TIMEOUT') === false) ) { this?.log?.debug?.( 'Nest API had an error performing subscription with connection uuid "%s"', uuid, error?.message ?? String(error), ); this?.log?.debug?.('Restarting Nest API subscription for connection uuid "%s"', uuid); } }) .finally(() => { setTimeout(() => this.#subscribeNest(uuid, fullRefresh), 1000); }); } async #subscribeProtobuf(uuid, firstRun) { if ( typeof this.#connections?.[uuid] !== 'object' || this.#connections?.[uuid]?.authorised === false || this.config?.options?.useGoogleAPI === false ) { // Not a valid connection object and/or we're not authorised return; } const calculate_message_size = (inputBuffer) => { let varint = 0; let shift = 0; for (let i = 1; i <= 5; i++) { // Start at index 1 (skip tag byte) let byte = inputBuffer[i]; varint |= (byte & 0x7f) << shift; if ((byte & 0x80) === 0) { return varint + i + 1; // +1 to include initial tag byte } shift += 7; } throw new Error('VarInt exceeds allowed bounds.'); }; const traverseTypes = (trait, callback) => { if (trait instanceof protobuf.Type === true) { callback(trait); } for (const nested of trait && trait.nestedArray ? trait.nestedArray : []) { traverseTypes(nested, callback); } }; // Attempt to load in protobuf files if not already done so if (this.#protobufRoot === null && fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true) { protobuf.util.Long = null; protobuf.configure(); this.#protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto')); if (this.#protobufRoot !== null) { this?.log?.debug?.('Loaded protobuf support files for Protobuf API'); } } if (this.#protobufRoot === null) { this?.log?.warn?.('Failed to loaded Protobuf API support files. This will cause certain Nest/Google devices to be un-supported'); return; } // We have loaded Protobuf proto files, so now dynamically build the 'observe' post body data let observeTraitsList = []; let observeBody = Buffer.alloc(0); let traitTypeObserveParam = this.#protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams'); let observeRequest = this.#protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest'); if (traitTypeObserveParam !== null && observeRequest !== null) { traverseTypes(this.#protobufRoot, (type) => { // We only want to have certain trait 'families' in our observe reponse we are building // This also depends on the account type we connected with // Nest accounts cannot observe camera/doorbell product traits if ( (this.#connections[uuid].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)) || (this.#connections[uuid].type === ACCOUNT_TYPE.GOOGLE && (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true || type.fullName.startsWith('.google.trait.product.camera') === true)) ) { observeTraitsList.push(traitTypeObserveParam.create({ traitType: type.fullName.replace(/^\.*|\.*$/g, '') })); } }); observeBody = observeRequest.encode(observeRequest.create({ stateTypes: [1, 2], traitTypeParams: observeTraitsList })).finish(); } if (firstRun === true) { this?.log?.debug?.('Starting Protobuf API trait observe for connection uuid "%s"', uuid); } fetchWrapper( 'post', 'https://' + this.#connections[uuid].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe', { headers: { referer: 'https://' + this.#connections[uuid].referer, 'User-Agent': USER_AGENT, Authorization: 'Basic ' + this.#connections[uuid].token, 'Content-Type': 'application/x-protobuf', 'X-Accept-Content-Transfer-Encoding': 'binary', 'X-Accept-Response-Streaming': 'true', }, keepalive: true, //timeout: (5 * 60000), }, observeBody, ) .then((response) => response.body) .then(async (data) => { let buffer = Buffer.alloc(0); for await (const chunk of data) { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); let messageSize = calculate_message_size(buffer); if (buffer.length >= messageSize) { let decodedMessage = {}; try { // Attempt to decode the Protobuf message(s) we extracted from the stream and get a JSON object representation decodedMessage = this.#protobufRoot .lookup('nestlabs.gateway.v2.ObserveResponse') .decode(buffer.subarray(0, messageSize)) .toJSON(); // Tidy up our received messages. 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 if (decodedMessage?.observeResponse?.[0]?.traitStates !== undefined) { let notAcceptedStatus = decodedMessage.observeResponse[0].traitStates.filter( (trait) => trait.stateTypes.includes('ACCEPTED') === false, ); let acceptedStatus = decodedMessage.observeResponse[0].traitStates.filter( (trait) => trait.stateTypes.includes('ACCEPTED') === true, ); let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel); decodedMessage.observeResponse[0].traitStates = ((notAcceptedStatus = notAcceptedStatus.filter( (trait) => difference.includes(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false, )), [...notAcceptedStatus, ...acceptedStatus]); } // 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 if (decodedMessage?.observeResponse?.[0]?.resourceMetas !== undefined) { decodedMessage.observeResponse[0].resourceMetas.map(async (resource) => { if ( resource.status === 'REMOVED' && (resource.resourceId.startsWith('STRUCTURE_') || resource.resourceId.startsWith('DEVICE_')) ) { // We have the removal of a 'home' and/or device // Tidy up tracked devices since this one is removed if (this.#trackedDevices[this.#rawData?.[resource.resourceId]?.value?.device_identity?.serialNumber] !== undefined) { // Remove any active running timers we have for this device if ( this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber]?.timers !== undefined ) { Object.values( this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber]?.timers, ).forEach((timers) => { clearInterval(timers); }); } // Send removed notice onto HomeKit device for it to process this.#eventEmitter?.emit?.( this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber].uuid, HomeKitDevice.REMOVE, {}, ); // Finally, remove from tracked devices delete this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber]; } delete this.#rawData[resource.resourceId]; } }); } // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } buffer = buffer.subarray(messageSize); // Remove the message from the beginning of the buffer if (typeof decodedMessage?.observeResponse?.[0]?.traitStates === 'object') { await Promise.all( decodedMessage.observeResponse[0].traitStates.map(async (trait) => { if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') { this.#rawData[trait.traitId.resourceId] = {}; this.#rawData[trait.traitId.resourceId].connection = uuid; this.#rawData[trait.traitId.resourceId].source = DATASOURCE.PROTOBUF_API; this.#rawData[trait.traitId.resourceId].value = {}; } this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] = typeof trait.patch.values !== 'undefined' ? trait.patch.values : {}; // We don't need to store the trait type, so remove it delete this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.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 ( trait.traitId.resourceId.startsWith('STRUCTURE_') === true && trait.traitId.traitLabel === 'structure_location' && isNaN(trait.patch.values?.geoCoordinate?.latitude) === false && isNaN(trait.patch.values?.geoCoordinate?.longitude) === false ) { this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeather( uuid, trait.traitId.resourceId, Number(trait.patch.values.geoCoordinate.latitude), Number(trait.patch.values.geoCoordinate.longitude), ); } }), ); await this.#processPostSubscribe(); } } } }) .catch((error) => { if ( error?.cause === undefined || (typeof error.cause === 'object' && String(error.cause).toUpperCase().includes('TIMEOUT') === false) ) { this?.log?.debug?.( 'Protobuf API had an error performing trait observe with connection uuid "%s". Error: "%s"', uuid, error?.message ?? String(error), ); this?.log?.debug?.('Restarting Protobuf API trait observe for connection uuid "%s"', uuid); } }) .finally(() => { setTimeout(() => this.#subscribeProtobuf(uuid, false), 1000); }); } async #processPostSubscribe() { Object.values(this.#processData('')).forEach((deviceData) => { if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && deviceData?.excluded === true) { // We haven't tracked this device before (ie: should be a new one) and but its excluded this?.log?.warn?.('Device "%s" is ignored due to it being marked as excluded', deviceData.description); // Track this device even though its excluded this.#trackedDevices[deviceData.serialNumber] = { uuid: HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, this.api, deviceData.serialNumber), rawDataUuid: deviceData.nest_google_uuid, source: undefined, timers: undefined, exclude: true, }; // If we're running under Homebridge, and the device is now marked as excluded and present in accessory cache // Then we'll unregister it from the Homebridge platform if (typeof this?.api?.unregisterPlatformAccessories === 'function') { let accessory = this.cachedAccessories.find( (accessory) => accessory?.UUID === this.#trackedDevices[deviceData.serialNumber].uuid, ); if (accessory !== undefined && typeof accessory === 'object') { this.api.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [accessory]); } } } if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && 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 let deviceClass = this.#deviceModules.get(deviceData.device_type); if (deviceClass !== undefined) { // We have found a device class for this device type, so we can create the device let accessoryName = (deviceData.manufacturer?.trim() || 'Nest') + ' ' + deviceClass.TYPE.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[^a-zA-Z0-9 ]+/g, ' ') .toLowerCase() .replace(/\b\w/g, (character) => character.toUpperCase()); let tempDevice = new deviceClass(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add(accessoryName, getDeviceHKCategory(deviceClass.TYPE), true); // Track this device once created this.#trackedDevices[deviceData.serialNumber] = { uuid: tempDevice.uuid, rawDataUuid: deviceData.nest_google_uuid, source: undefined, timers: {}, exclude: false, }; // Optional things for each device type if ( deviceClass.TYPE === DEVICE_TYPE.CAMERA || deviceClass.TYPE === DEVICE_TYPE.DOORBELL || deviceClass.TYPE === DEVICE_TYPE.FLOODLIGHT ) { // Setup polling loop for camera/doorbell zone data // This is only required for Nest API data sources as these details are present in Protobuf API clearInterval(this.#trackedDevices?.[deviceData.serialNumber]?.timers?.zones); this.#trackedDevices[deviceData.serialNumber].timers.zones = setInterval(async () => { let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid; if ( this.#rawData?.[nest_google_uuid]?.value !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.NEST_API ) { try { let response = await fetchWrapper( 'get', this.#rawData[nest_google_uuid].value.nexus_api_http_server_url + '/cuepoint_category/' + nest_google_uuid.split('.')[1], { headers: { referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer, 'User-Agent': USER_AGENT, [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]: this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value + this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token, }, timeout: CAMERA_ZONE_POLLING, }, ); let data = await response.json(); // Transform activity zones if present let zones = Array.isArray(data) === true ? data .filter((zone) => zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') .map((zone) => ({ id: zone.id === 0 ? 1 : zone.id, name: HomeKitDevice.makeHomeKitName(zone.label), hidden: zone.hidden === true, uri: zone.nexusapi_image_uri, })) : []; // Update internal structure with new zone details. // We do a test to see if it's still present, not interval loop not finished or device removed if (this.#rawData?.[nest_google_uuid]?.value !== undefined) { this.#rawData[nest_google_uuid].value.activity_zones = zones; // Send updated data onto HomeKit device for it to process this.#trackedDevices?.[deviceData?.serialNumber]?.uuid && this.#eventEmitter?.emit?.(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, { activity_zones: zones, }); } } catch (error) { // Log debug message if it wasn't a timeout if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) { this?.log?.debug?.( 'Nest API had error retrieving camera/doorbell activity zones for "%s". Error was "%s"', deviceData.description, error?.code, ); } } } }, CAMERA_ZONE_POLLING); // Setup polling loop for camera/doorbell alert data, clearing any existing polling loop clearInterval(this.#trackedDevices?.[deviceData.serialNumber]?.timers?.alerts); this.#trackedDevices[deviceData.serialNumber].timers.alerts = setInterval(async () => { let alerts = []; // No alerts to processed yet let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid; if ( this.#rawData?.[nest_google_uuid]?.value !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.PROTOBUF_API ) { let commandResponse = await this.#protobufCommand( this.#rawData[nest_google_uuid].connection, 'ResourceApi', 'SendCommand', { resourceRequest: { resourceId: nest_google_uuid, requestId: crypto.randomUUID(), }, resourceCommands: [ { traitLabel: 'camera_observation_history', command: { type_url: 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest', value: { // We want camera history from now for upto 30secs from now queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 }, queryEndTime: { seconds: Math.floor((Date.now() + 30000) / 1000), nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6, }, }, }, }, ], }, ); if ( Array.isArray( commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow?.cameraEvent, ) === true ) { alerts = commandResponse.sendCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent .map((event) => ({ playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000, start_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000, end_time: parseInt(event.endTime.seconds) * 1000 + parseInt(event.endTime.nanos) / 1000000, id: event.eventId, zone_ids: typeof event.activityZone === 'object' ? event.activityZone.map((zone) => (zone?.zoneIndex !== undefined ? zone.zoneIndex : zone.internalIndex)) : [], types: event.eventType .map((event) => { if (event === 'EVENT_UNFAMILIAR_FACE') { return 'unfamiliar-face'; } if (event === 'EVENT_PERSON_TALKING') { return 'personHeard'; } if (event === 'EVENT_DOG_BARKING') { return 'dogBarking'; } return event.startsWith('EVENT_') ? event.split('EVENT_')[1].toLowerCase() : ''; }) .filter((event) => event), })) .sort((a, b) => b.start_time - a.start_time); } } if ( this.#rawData?.[nest_google_uuid]?.value !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.NEST_API ) { try { let response = await fetchWrapper( 'get', this.#rawData[nest_google_uuid].value.nexus_api_http_server_url + '/cuepoint/' + nest_google_uuid.split('.')[1] + '/2?start_time=' + Math.floor(Date.now() / 1000 - 30), { headers: { referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer, 'User-Agent': USER_AGENT, [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]: this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value + this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token, }, timeout: CAMERA_ALERT_POLLING, retry: 3, }, ); let data = await response.json(); alerts = Array.isArray(data) === true ? data .map((alert) => { alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1)); if (alert.zone_ids.length === 0) { alert.zone_ids.push(1); } return { playback_time: alert.playback_time, start_time: alert.start_time, end_time: alert.end_time, id: alert.id, zone_ids: alert.zone_ids, types: alert.types, }; }) .sort((a, b) => b.start_time - a.start_time) : []; } catch (error) { if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) { this?.log?.debug?.( 'Nest API had error retrieving camera/doorbell activity notifications for "%s". Error was "%s"', deviceData.description, error?.code, ); } } } // Update internal structure with new alerts. // We do a test to see if its still present not interval loop not finished or device removed if (this.#rawData?.[nest_google_uuid]?.value !== undefined) { this.#rawData[nest_