UNPKG

homebridge-nest-accfactory

Version:

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

1,055 lines (963 loc) 174 kB
// Nest System communications // Part of homebridge-nest-accfactory // // Code version 2025/03/20 // 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 process from 'node:process'; import child_process from 'node:child_process'; import { fileURLToPath } from 'node:url'; // Import our modules import HomeKitDevice from './HomeKitDevice.js'; import NestCamera from './camera.js'; import NestDoorbell from './doorbell.js'; import NestFloodlight from './floodlight.js'; import NestProtect from './protect.js'; import NestTemperatureSensor from './tempsensor.js'; import NestWeather from './weather.js'; import NestThermostat from './thermostat.js'; const CAMERAALERTPOLLING = 2000; // Camera alerts polling timer const CAMERAZONEPOLLING = 30000; // Camera zones changes polling timer const WEATHERPOLLING = 300000; // Weather data polling timer const NESTAPITIMEOUT = 10000; // Nest API timeout const USERAGENT = 'Nest/5.78.0 (iOScom.nestlabs.jasper.release) os=18.0'; // User Agent string const FFMPEGVERSION = '6.0'; // Minimum version of ffmpeg we require const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname // We handle the connections to Nest/Google // Perform device management (additions/removals/updates) export default class NestAccfactory { static DeviceType = { THERMOSTAT: 'thermostat', TEMPSENSOR: 'temperature', SMOKESENSOR: 'protect', CAMERA: 'camera', DOORBELL: 'doorbell', FLOODLIGHT: 'floodlight', WEATHER: 'weather', LOCK: 'lock', // yet to implement ALARM: 'alarm', // yet to implement }; static DataSource = { REST: 'REST', // From the REST API PROTOBUF: 'Protobuf', // From the Protobuf API }; static GoogleAccount = 'google'; // Google account connection static NestAccount = 'nest'; // Nest account connection cachedAccessories = []; // Track restored cached accessories // Internal data only for this class #connections = {}; // Object of confirmed connections #rawData = {}; // Cached copy of data from both Rest and Protobuf APIs #eventEmitter = new EventEmitter(); // Used for object messaging from this platform #connectionTimer = undefined; #protobufRoot = null; // Protobuf loaded protos #trackedDevices = {}; // Object of devices we've created. used to track data source type, comms uuid. key'd by serial # constructor(log, config, api) { this.config = config; this.log = log; this.api = api; // Perform validation on the configuration passed into us and set defaults if not present // Build our accounts connection object. Allows us to have multiple diffent account connections under the one accessory Object.keys(this.config).forEach((key) => { if (this.config[key]?.access_token !== undefined && this.config[key].access_token !== '') { // Nest account connection, assign a random UUID for each connection this.#connections[crypto.randomUUID()] = { type: NestAccfactory.NestAccount, authorised: false, access_token: this.config[key].access_token, fieldTest: this.config[key]?.fieldTest === true, referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com', restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com', cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com', protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com', }; } if ( this.config[key]?.issuetoken !== undefined && this.config[key].issuetoken !== '' && this.config[key]?.cookie !== undefined && this.config[key].cookie !== '' ) { // Google account connection, assign a random UUID for each connection this.#connections[crypto.randomUUID()] = { type: NestAccfactory.GoogleAccount, authorised: false, issuetoken: this.config[key].issuetoken, cookie: this.config[key].cookie, fieldTest: this.config[key]?.fieldTest === true, referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com', restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com', cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com', protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com', }; } }); // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back. if (Object.keys(this.#connections).length === 0) { this?.log?.error && this.log.error('No connections have been specified in the JSON configuration. Please review'); return; } if (typeof this.config?.options !== 'object') { this.config.options = {}; } this.config.options.eveHistory = this.config.options?.eveHistory === true; this.config.options.elevation = isNaN(this.config.options?.elevation) === false ? Number(this.config.options.elevation) : 0; this.config.options.weather = this.config.options?.weather === true; this.config.options.hksv = this.config.options?.hksv === true; // Controls what APIs we use, default is to use both REST and protobuf APIs this.config.options.restAPI = this.config.options?.restAPI === true || this.config.options?.restAPI === undefined; this.config.options.protobufAPI = this.config.options?.protobufAPI === true || this.config.options?.protobufAPI === undefined; // Get configuration for max number of concurrent 'live view' streams. For HomeKit Secure Video, this will always be 1 this.config.options.maxStreams = isNaN(this.config.options?.maxStreams) === false && this.deviceData?.hksv === false ? Number(this.config.options.maxStreams) : this.deviceData?.hksv === true ? 1 : 2; // Check if a ffmpeg binary exist via a specific path in configuration OR /usr/local/bin this.config.options.ffmpeg = {}; this.config.options.ffmpeg.debug = this.config.options?.ffmpegDebug === true; this.config.options.ffmpeg.binary = path.resolve( typeof this.config.options?.ffmpegPath === 'string' && this.config.options.ffmpegPath !== '' ? this.config.options.ffmpegPath : '/usr/local/bin', ); // If the path doesn't include 'ffmpeg' on the end, we'll add it here if (this.config.options.ffmpeg.binary.endsWith('/ffmpeg') === false) { this.config.options.ffmpeg.binary = this.config.options.ffmpeg.binary + '/ffmpeg'; } this.config.options.ffmpeg.version = undefined; this.config.options.ffmpeg.libspeex = false; this.config.options.ffmpeg.libopus = false; this.config.options.ffmpeg.libx264 = false; this.config.options.ffmpeg.libfdk_aac = false; if (fs.existsSync(this.config.options.ffmpeg.binary) === false) { if (this?.log?.warn) { this.log.warn('Specified ffmpeg binary "%s" was not found', this.config.options.ffmpeg.binary); this.log.warn('Stream video/recording from camera/doorbells will be unavailable'); } // If we flag ffmpegPath as undefined, no video streaming/record support enabled for camers/doorbells this.config.options.ffmpeg.binary = undefined; } // Process ffmpeg binary to see if we can use it if (fs.existsSync(this.config.options.ffmpeg.binary) === true) { let ffmpegProcess = child_process.spawnSync(this.config.options.ffmpeg.binary, ['-version'], { env: process.env, }); if (ffmpegProcess.stdout !== null) { // Determine ffmpeg version this.config.options.ffmpeg.version = ffmpegProcess.stdout .toString() .match(/(?:ffmpeg version:(\d+)\.)?(?:(\d+)\.)?(?:(\d+)\.\d+)(.*?)/gim)[0]; // Determine what libraries ffmpeg is compiled with this.config.options.ffmpeg.libspeex = ffmpegProcess.stdout.toString().includes('--enable-libspeex') === true; this.config.options.ffmpeg.libopus = ffmpegProcess.stdout.toString().includes('--enable-libopus') === true; this.config.options.ffmpeg.libx264 = ffmpegProcess.stdout.toString().includes('--enable-libx264') === true; this.config.options.ffmpeg.libfdk_aac = ffmpegProcess.stdout.toString().includes('--enable-libfdk-aac') === true; if ( this.config.options.ffmpeg.version.localeCompare(FFMPEGVERSION, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper', }) === -1 || this.config.options.ffmpeg.libspeex === false || this.config.options.ffmpeg.libopus === false || this.config.options.ffmpeg.libx264 === false || this.config.options.ffmpeg.libfdk_aac === false ) { this?.log?.warn && this.log.warn('ffmpeg binary "%s" does not meet the minimum support requirements', this.config.options.ffmpeg.binary); if ( this.config.options.ffmpeg.version.localeCompare(FFMPEGVERSION, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper', }) === -1 ) { this?.log?.warn && this.log.warn( 'Minimum binary version is "%s", however the installed version is "%s"', FFMPEGVERSION, this.config.options.ffmpeg.version, ); this?.log?.warn && this.log.warn('Stream video/recording from camera/doorbells will be unavailable'); this.config.options.ffmpeg.binary = undefined; // No ffmpeg since below min version } if ( this.config.options.ffmpeg.libspeex === false && this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === true ) { this?.log?.warn && this.log.warn('Missing libspeex in ffmpeg binary, talkback on certain camera/doorbells will be unavailable'); } if ( this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === false && this.config.options.ffmpeg.libopus === false ) { this?.log?.warn && this.log.warn('Missing libfdk_aac and libopus in ffmpeg binary, audio from camera/doorbells will be unavailable'); } if (this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === false) { this?.log?.warn && this.log.warn('Missing libfdk_aac in ffmpeg binary, audio from camera/doorbells will be unavailable'); } if ( this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === true && this.config.options.ffmpeg.libopus === false ) { this?.log?.warn && this.log.warn( 'Missing libopus in ffmpeg binary, audio (including talkback) from certain camera/doorbells will be unavailable', ); } if (this.config.options.ffmpeg.libx264 === false) { this?.log?.warn && this.log.warn('Missing libx264 in ffmpeg binary, stream video/recording from camera/doorbells will be unavailable'); this.config.options.ffmpeg.binary = undefined; // No ffmpeg since we do not have all the required libraries } } } } if (this.config.options.ffmpeg.binary !== undefined) { this?.log?.success && this.log.success('Found valid ffmpeg binary in %s', this.config.options.ffmpeg.binary); } if (this.api instanceof EventEmitter === true) { this.api.on('didFinishLaunching', async () => { // We got notified that Homebridge has finished loading, so we are ready to process this.discoverDevices(); // We'll check connection status every 15 seconds. We'll also handle token expiry/refresh this way clearInterval(this.#connectionTimer); this.#connectionTimer = setInterval(this.discoverDevices.bind(this), 15000); }); this.api.on('shutdown', async () => { // We got notified that Homebridge is shutting down // Perform cleanup some internal cleaning up this.#eventEmitter.removeAllListeners(); Object.values(this.#trackedDevices).forEach((value) => { if (value?.timers !== undefined) { Object.values(value?.timers).forEach((timers) => { clearInterval(timers); }); } }); this.#trackedDevices = {}; clearInterval(this.#connectionTimer); this.#connectionTimer = undefined; 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(uuid, values); }); this.#eventEmitter.addListener(HomeKitDevice.GET, async (uuid, values) => { let results = await this.#get(uuid, values); // Send the results back to the device via a special event (only if still active) if (this.#eventEmitter !== undefined) { 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 && 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 discoverDevices() { Object.keys(this.#connections).forEach((uuid) => { if (this.#connections[uuid].authorised === false) { this.#connect(uuid).then(() => { if (this.#connections[uuid].authorised === true && this.config.options?.restAPI === true) { this.#subscribeREST(uuid, true); } if (this.#connections[uuid].authorised === true && this.config.options?.protobufAPI === true) { this.#subscribeProtobuf(uuid, true); } }); } }); } async #connect(connectionUUID) { if (typeof this.#connections?.[connectionUUID] === 'object') { if (this.#connections[connectionUUID].type === NestAccfactory.GoogleAccount) { // 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?.info && this.log.info( 'Performing Google account authorisation ' + (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''), ); await fetchWrapper('get', this.#connections[connectionUUID].issuetoken, { headers: { referer: 'https://accounts.google.com/o/oauth2/iframe', 'User-Agent': USERAGENT, cookie: this.#connections[connectionUUID].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[connectionUUID].referer, 'User-Agent': USERAGENT, 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[connectionUUID].restAPIHost + '/session', { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, Authorization: 'Basic ' + googleToken, }, }) .then((response) => response.json()) .then((data) => { // Store successful connection details this.#connections[connectionUUID].authorised = true; this.#connections[connectionUUID].userID = data.userid; this.#connections[connectionUUID].transport_url = data.urls.transport_url; this.#connections[connectionUUID].weather_url = data.urls.weather_url; this.#connections[connectionUUID].token = googleToken; this.#connections[connectionUUID].cameraAPI = { key: 'Authorization', value: 'Basic ', // NOTE: extra space required token: googleToken, oauth2: googleOAuth2Token, }; // Set timeout for token expiry refresh clearTimeout(this.#connections[connectionUUID].timer); this.#connections[connectionUUID].timer = setTimeout( () => { this?.log?.info && this.log.info('Performing periodic token refresh for Google account'); this.#connect(connectionUUID); }, (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000, ); // Refresh just before token expiry this?.log?.success && this.log.success('Successfully authorised using Google account'); }); }); }) // 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[connectionUUID].authorised = false; this?.log?.debug && this.log.debug( 'Failed to connect to gateway using credential details for connection uuid "%s". A periodic retry event will be triggered', connectionUUID, ); this?.log?.error && this.log.error('Authorisation failed using Google account'); }); } if (this.#connections[connectionUUID].type === NestAccfactory.NestAccount) { // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later this?.log?.info && this.log.info( 'Performing Nest account authorisation ' + (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''), ); await fetchWrapper( 'post', 'https://webapi.' + this.#connections[connectionUUID].cameraAPIHost + '/api/v1/login.login_nest', { withCredentials: true, headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, 'Content-Type': 'application/x-www-form-urlencoded', }, }, Buffer.from('access_token=' + this.#connections[connectionUUID].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[connectionUUID].restAPIHost + '/session', { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, Authorization: 'Basic ' + this.#connections[connectionUUID].access_token, }, }) .then((response) => response.json()) .then((data) => { // Store successful connection details this.#connections[connectionUUID].authorised = true; this.#connections[connectionUUID].userID = data.userid; this.#connections[connectionUUID].transport_url = data.urls.transport_url; this.#connections[connectionUUID].weather_url = data.urls.weather_url; this.#connections[connectionUUID].token = this.#connections[connectionUUID].access_token; this.#connections[connectionUUID].cameraAPI = { key: 'cookie', value: this.#connections[connectionUUID].fieldTest === true ? 'website_ft=' : 'website_2=', token: nestToken, }; // Set timeout for token expiry refresh clearTimeout(this.#connections[connectionUUID].timer); this.#connections[connectionUUID].timer = setTimeout( () => { this?.log?.info && this.log.info('Performing periodic token refresh for Nest account'); this.#connect(connectionUUID); }, 1000 * 3600 * 24, ); // Refresh token every 24hrs this?.log?.success && this.log.success('Successfully authorised using Nest account'); }); }) // 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[connectionUUID].authorised = false; this?.log?.debug && this.log.debug('Failed to connect using credential details for connection uuid "%s"', connectionUUID); this?.log?.error && this.log.error('Authorisation failed using Nest account'); }); } } } async #subscribeREST(connectionUUID, fullRefresh) { if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) { // 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', ]; // By default, setup for a full data read from the REST API let subscribeURL = 'https://' + this.#connections[connectionUUID].restAPIHost + '/api/0.1/user/' + this.#connections[connectionUUID].userID + '/app_launch'; let subscribeJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] }; if (fullRefresh === false) { // We have data stored from this REST API, so setup read using known object subscribeURL = this.#connections[connectionUUID].transport_url + '/v6/subscribe'; subscribeJSONData = { objects: [] }; Object.entries(this.#rawData) // eslint-disable-next-line no-unused-vars .filter(([object_key, object]) => object.source === NestAccfactory.DataSource.REST && object.connection === connectionUUID) .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 && this.log.debug('Starting REST API subscribe for connection uuid "%s"', connectionUUID); } fetchWrapper( 'post', subscribeURL, { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, Authorization: 'Basic ' + this.#connections[connectionUUID].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.#getWeatherData( connectionUUID, 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 : []; await fetchWrapper( 'get', 'https://webapi.' + this.#connections[connectionUUID].cameraAPIHost + '/api/cameras.get_with_properties?uuid=' + value.object_key.split('.')[1], { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, [this.#connections[connectionUUID].cameraAPI.key]: this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token, }, timeout: NESTAPITIMEOUT, }, ) .then((response) => response.json()) .then((data) => { value.value.properties = data.items[0].properties; }) .catch((error) => { if ( error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug ) { this.log.debug('REST API had error retrieving camera/doorbell details during subscribe. Error was "%s"', error?.code); } }); value.value.activity_zones = typeof this.#rawData[value.object_key]?.value?.activity_zones === 'object' ? this.#rawData[value.object_key].value.activity_zones : []; await fetchWrapper('get', value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, [this.#connections[connectionUUID].cameraAPI.key]: this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token, }, timeout: NESTAPITIMEOUT, }) .then((response) => response.json()) .then((data) => { let zones = []; data.forEach((zone) => { if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') { zones.push({ id: zone.id === 0 ? 1 : zone.id, name: makeHomeKitName(zone.label), hidden: zone.hidden === true, uri: zone.nexusapi_image_uri, }); } }); value.value.activity_zones = zones; }) .catch((error) => { if ( error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug ) { this.log.debug( 'REST API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"', error?.code, ); } }); } 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 REST 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 if (this.#eventEmitter !== undefined) { 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 REST 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 = connectionUUID; this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST; this.#rawData[value.object_key].value = {}; } // Finally, update our internal raw REST 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 && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) { this.log.debug('REST API had an error performing subscription with connection uuid "%s"', connectionUUID); this.log.debug('Error was "%s"', error); } }) .finally(() => { this?.log?.debug && this.log.debug('Restarting REST API subscribe for connection uuid "%s"', connectionUUID); setTimeout(this.#subscribeREST.bind(this, connectionUUID, fullRefresh), 1000); }); } async #subscribeProtobuf(connectionUUID, firstRun) { if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) { // Not a valid connection object and/or we're not authorised return; } const calculate_message_size = (inputBuffer) => { // First byte in the is a tag type?? // Following is a varint type // After varint size, is the buffer content let varint = 0; let bufferPos = 0; let currentByte; for (;;) { currentByte = inputBuffer[bufferPos + 1]; // Offset in buffer + 1 to skip over starting tag varint |= (currentByte & 0x7f) << (bufferPos * 7); bufferPos += 1; if (bufferPos > 5) { throw new Error('VarInt exceeds allowed bounds.'); } if ((currentByte & 0x80) !== 0x80) { break; } } // Return length of message in buffer return varint + bufferPos + 1; }; const traverseTypes = (currentTrait, callback) => { if (currentTrait instanceof protobuf.Type) { callback(currentTrait); } if (currentTrait.nestedArray) { currentTrait.nestedArray.map((trait) => { traverseTypes(trait, 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 && this.log.debug('Loaded protobuf support files for Protobuf API'); } } if (this.#protobufRoot === null) { this?.log?.warn && 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[connectionUUID].type === NestAccfactory.NestAccount && 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[connectionUUID].type === NestAccfactory.GoogleAccount && (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 && this.log.debug('Starting Protobuf API trait observe for connection uuid "%s"', connectionUUID); } fetchWrapper( 'post', 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe', { headers: { referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, Authorization: 'Basic ' + this.#connections[connectionUUID].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 if (this.#eventEmitter !== undefined) { 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 = connectionUUID; this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF; 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 REST 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.#getWeatherData( connectionUUID, trait.traitId.resourceId, Number(trait.patch.values.geoCoordinate.latitude), Number(trait.patch.values.geoCoordinate.longitude), ); } }), ); await this.#processPostSubscribe(); } } } }) .catch((error) => { if (error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) { this.log.debug('Protobuf API had an error performing trait observe with connection uuid "%s"', connectionUUID); this.log.debug('Error was "%s"', error); } }) .finally(() => { this?.log?.debug && this.log.debug('Restarting Protobuf API trait observe for connection uuid "%s"', connectionUUID); setTimeout(this.#subscribeProtobuf.bind(this, connectionUUID, 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 && 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: undefined, rawDataUuid: deviceData.nest_google_uuid, source: undefined, exclude: true, }; } 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 if (deviceData.device_type === NestAccfactory.DeviceType.THERMOSTAT && typeof NestThermostat === 'function') { // Nest Thermostat(s) - Categories.THERMOSTAT = 9 let tempDevice = new NestThermostat(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Thermostat', 9, true); // Track this device once created this.#trackedDevices[deviceData.serialNumber] = { uuid: tempDevice.uuid, rawDataUuid: deviceData.nest_google_uuid, source: undefined, }; } if (deviceData.device_type === NestAccfactory.DeviceType.TEMPSENSOR && typeof NestTemperatureSensor === 'function') { // Nest Temperature Sensor - Categories.SENSOR = 10; let tempDevice = new NestTemperatureSensor(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Temperature Sensor', 10, true); // Track this device once created this.#trackedDevices[deviceData.serialNumber] = { uuid: tempDevice.uuid, rawDataUuid: deviceData.nest_google_uuid, source: undefined, }; } if (deviceData.device_type === NestAccfactory.DeviceType.SMOKESENSOR && typeof NestProtect === 'function') { // Nest Protect(s) - Categories.SENSOR = 10 let tempDevice = new NestProtect(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Protect', 10, true); // Track this device once created this.#trackedDevices[deviceData.serialNumber] = { uuid: tempDevice.uuid, rawDataUuid: deviceData.nest_google_uuid, source: undefined,