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