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