From 36c31577df53bb6644c22aa2578457ec932488d3 Mon Sep 17 00:00:00 2001 From: Yn0rt0nthec4t Date: Sat, 7 Sep 2024 15:24:58 +1000 Subject: [PATCH 1/5] 0.1.6 updated source files --- Dockerfile | 3 + package.json | 4 +- src/HomeKitDevice.js | 17 +- src/camera.js | 258 ++++------ src/doorbell.js | 16 +- src/{nexusstreamer.js => nexustalk.js} | 556 +++++---------------- src/streamer.js | 344 +++++++++++++ src/system.js | 665 ++++++++++++++++--------- src/webrtc.js | 55 ++ 9 files changed, 1073 insertions(+), 845 deletions(-) rename src/{nexusstreamer.js => nexustalk.js} (51%) create mode 100644 src/streamer.js create mode 100644 src/webrtc.js diff --git a/Dockerfile b/Dockerfile index 93bd201..9b7d9f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,11 +74,14 @@ RUN ./configure \ #--enable-vaapi \ --disable-ffnvcodec \ #--enable-amf \ + --disable-doc \ --disable-debug \ --disable-shared \ --enable-pthreads \ --enable-static \ --enable-version3 \ + --enable-pthreads \ + --enable-runtime-cpudetect \ ${FFMPEG_EXTRA_OPTIONS} \ && make -j 4 \ && make install diff --git a/package.json b/package.json index d96f485..112ae6e 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,12 @@ "sensor" ], "devDependencies": { - "@eslint/js": "^9.9.1", + "@eslint/js": "^9.10.0", "@stylistic/eslint-plugin": "^2.7.2", "@types/node": "^20.16.1", "@typescript-eslint/parser": "^8.4.0", "copyfiles": "^2.4.1", - "eslint": "^9.9.1", + "eslint": "^9.10.0", "nodemon": "^3.1.4", "prettier": "^3.3.3", "prettier-eslint": "^16.3.0", diff --git a/src/HomeKitDevice.js b/src/HomeKitDevice.js index f910545..9e91f03 100644 --- a/src/HomeKitDevice.js +++ b/src/HomeKitDevice.js @@ -37,7 +37,7 @@ // HomeKitDevice.updateServices(deviceData) // HomeKitDevice.messageServices(type, message) // -// Code version 3/9/2024 +// Code version 6/9/2024 // Mark Hulskamp 'use strict'; @@ -393,14 +393,13 @@ export default class HomeKitDevice { return; } - // <---- TODO - // Send event with data to get. Once get has completed, callback will be called with the requested data - //this.#eventEmitter.emit(HomeKitDevice.GET, this.deviceData.uuid, values); - // - // await .... - // return gottenValues; - // <---- TODO - // Probable need some sort of await event + // Send event with data to get + // Once get has completed, we'll get an eevent back with the requested data + this.#eventEmitter.emit(HomeKitDevice.GET, this.deviceData.uuid, values); + + // This should always return, but we probably should put in a timeout? + let results = await EventEmitter.once(this.#eventEmitter, HomeKitDevice.GET + '->' + this.deviceData.uuid); + return results?.[0]; } async #message(type, message) { diff --git a/src/camera.js b/src/camera.js index 3ff95cf..03818e3 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,7 +1,7 @@ // Nest Cameras // Part of homebridge-nest-accfactory // -// Code version 3/9/2024 +// Code version 7/9/2024 // Mark Hulskamp 'use strict'; @@ -17,23 +17,21 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -// Define external module requirements -import axios from 'axios'; - // Define our modules import HomeKitDevice from './HomeKitDevice.js'; -import NexusStreamer from './nexusstreamer.js'; +import NexusTalk from './nexustalk.js'; +//import WebRTC from './webrtc.js'; +let WebRTC = undefined; const CAMERAOFFLINEJPGFILE = 'Nest_camera_offline.jpg'; // Camera offline jpg image file const CAMERAOFFJPGFILE = 'Nest_camera_off.jpg'; // Camera video off jpg image file const MP4BOX = 'mp4box'; // MP4 box fragement event for HKSV recording -const USERAGENT = 'Nest/5.75.0 (iOScom.nestlabs.jasper.release) os=17.4.1'; // User Agent string const SNAPSHOTCACHETIMEOUT = 30000; // Timeout for retaining snapshot image (in milliseconds) const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname export default class NestCamera extends HomeKitDevice { controller = undefined; // HomeKit Camera/Doorbell controller service - NexusStreamer = undefined; // Object for the NexusTalk Streamer + streamer = undefined; // Streamer object for live/recording stream motionServices = undefined; // Object of Camera/Doorbell motion sensor(s) operatingModeService = undefined; // Link to camera/doorbell operating mode service personTimer = undefined; // Cooldown timer for person/face events @@ -82,16 +80,41 @@ export default class NestCamera extends HomeKitDevice { // Setup additional services/characteristics after we have a controller created this.createCameraServices(); - // Setup our streaming object + // Depending on the streaming profiles that the camera supports, this will be either nexustalk or webrtc // We'll also start pre-buffering if required for HKSV - this.NexusStreamer = new NexusStreamer(this.deviceData, { - log: this.log, - buffer: - this.deviceData.hksv === true && - this?.controller?.recordingManagement?.recordingManagementService !== undefined && - this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === - this.hap.Characteristic.Active.ACTIVE, - }); + if (this.deviceData.streaming_protocols.includes('PROTOCOL_WEBRTC') === true && this.streamer === undefined && WebRTC !== undefined) { + this.streamer = new WebRTC(this.deviceData, { + log: this.log, + buffer: + this.deviceData.hksv === true && + this?.controller?.recordingManagement?.recordingManagementService !== undefined && + this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === + this.hap.Characteristic.Active.ACTIVE, + }); + } + + if ( + this.deviceData.streaming_protocols.includes('PROTOCOL_NEXUSTALK') === true && + this.streamer === undefined && + NexusTalk !== undefined + ) { + this.streamer = new NexusTalk(this.deviceData, { + log: this.log, + buffer: + this.deviceData.hksv === true && + this?.controller?.recordingManagement?.recordingManagementService !== undefined && + this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === + this.hap.Characteristic.Active.ACTIVE, + }); + } + + if (this.streamer === undefined) { + this?.log?.error && + this.log.error( + 'No suitable streaming protocol is present for "%s". Streaming and recording will be unavailable', + this.deviceData.description, + ); + } // Setup linkage to EveHome app if configured todo so if ( @@ -107,7 +130,9 @@ export default class NestCamera extends HomeKitDevice { // Create extra details for output let postSetupDetails = []; this.deviceData.hksv === true && - postSetupDetails.push('HomeKit Secure Video' + (this.NexusStreamer.isBuffering() === true ? ' and recording buffer' : '')); + postSetupDetails.push( + 'HomeKit Secure Video support' + (this.streamer?.isBuffering() === true ? ' and recording buffer started' : ''), + ); return postSetupDetails; } @@ -117,9 +142,7 @@ export default class NestCamera extends HomeKitDevice { this.personTimer = clearTimeout(this.personTimer); this.snapshotTimer = clearTimeout(this.snapshotTimer); - if (this.NexusStreamer !== undefined && this.NexusStreamer.isBuffering() === true) { - this.NexusStreamer.stopBuffering(); // Stop any buffering - } + this.streamer?.isBuffering() === true && this.streamer.stopBuffering(); // Stop any on-going HomeKit sessions, either live or recording // We'll terminate any ffmpeg, rtpSpliter etc processes @@ -136,15 +159,18 @@ export default class NestCamera extends HomeKitDevice { }); // Remove any motion services we created - this.motionServices.forEach((service) => { + Object.values(this.motionServices).forEach((service) => { service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); this.accessory.removeService(service); }); + + // Remove the camera controller this.accessory.removeController(this.controller); + this.operatingModeService = undefined; this.#hkSessions = undefined; this.motionServices = undefined; - this.NexusStreamer = undefined; + this.streamer = undefined; this.controller = undefined; } @@ -170,6 +196,15 @@ export default class NestCamera extends HomeKitDevice { return; } + if (this.streamer === undefined) { + this?.log?.error && + this.log.error( + 'Received request to start recording for "%s" however we do not any associated streaming protocol supported', + this.deviceData.description, + ); + return; + } + // Build our ffmpeg command string for recording the video/audio stream let commandLine = '-hide_banner -nostats' + @@ -316,11 +351,12 @@ export default class NestCamera extends HomeKitDevice { } }); - this.NexusStreamer.startRecordStream( - sessionID, - this.#hkSessions[sessionID].ffmpeg.stdin, - this.#hkSessions[sessionID]?.ffmpeg?.stdio?.[3] ? this.#hkSessions[sessionID].ffmpeg.stdio[3] : null, - ); + this.streamer !== undefined && + this.streamer.startRecordStream( + sessionID, + this.#hkSessions[sessionID].ffmpeg.stdin, + this.#hkSessions[sessionID]?.ffmpeg?.stdio?.[3] ? this.#hkSessions[sessionID].ffmpeg.stdio[3] : null, + ); this?.log?.info && this.log.info( @@ -369,7 +405,8 @@ export default class NestCamera extends HomeKitDevice { } closeRecordingStream(sessionID, closeReason) { - this.NexusStreamer.stopRecordStream(sessionID); // Stop the associated recording stream + // Stop the associated recording stream + this.streamer !== undefined && this.streamer.stopRecordStream(sessionID); if (typeof this.#hkSessions?.[sessionID] === 'object') { if (this.#hkSessions[sessionID]?.ffmpeg !== undefined) { @@ -385,7 +422,7 @@ export default class NestCamera extends HomeKitDevice { // Log recording finished messages depending on reason if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) { - this?.log?.success && this.log.success('Completed recording from "%s"', this.deviceData.description); + this?.log?.info && this.log.info('Completed recording from "%s"', this.deviceData.description); } else { this?.log?.warn && this.log.warn( @@ -397,26 +434,16 @@ export default class NestCamera extends HomeKitDevice { } updateRecordingActive(enableRecording) { - // We'll use the change here to determine if we start/stop any buffering. - if (this.NexusStreamer === undefined) { - return; - } - - if (enableRecording === true) { + if (enableRecording === true && this.streamer?.isBuffering() === false) { // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc // Make sure have appropriate bandwidth!!! this?.log?.info && this.log.info('Recording was turned on for "%s"', this.deviceData.description); - - if (this.NexusStreamer.isBuffering() === false) { - this.NexusStreamer.startBuffering(); - } + this.streamer.startBuffering(); } - if (enableRecording === false) { - if (this.NexusStreamer.isBuffering() === true) { - this.NexusStreamer.stopBuffering(); - } + if (enableRecording === false && this.streamer?.isBuffering() === true) { + this.streamer.stopBuffering(); this?.log?.warn && this.log.warn('Recording was turned off for "%s"', this.deviceData.description); } } @@ -433,80 +460,16 @@ export default class NestCamera extends HomeKitDevice { let imageBuffer = undefined; if (this.deviceData.streaming_enabled === true && this.deviceData.online === true) { - if ( - this.deviceData.hksv === false && - typeof this.snapshotEvent === 'object' && - this.snapshotEvent.type !== '' && - this.snapshotEvent.done === false - ) { - // Grab event snapshot from camera/doorbell stream for a non-HKSV camera - let request = { - method: 'get', - url: - this.deviceData.nexus_api_http_server_url + - '/event_snapshot/' + - this.deviceData.uuid.split('.')[1] + - '/' + - this.snapshotEvent.id + - '?crop_type=timeline&width=' + - snapshotRequestDetails.width + - '&cachebuster=' + - Math.floor(Date.now() / 1000), - headers: { - 'User-Agent': USERAGENT, - accept: '*/*', - [this.deviceData.apiAccess.key]: this.deviceData.apiAccess.value + this.deviceData.apiAccess.token, - }, - responseType: 'arraybuffer', - timeout: 3000, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera API snapshot failed with error'); - } - - this.snapshotEvent.done = true; // Successfully got the snapshot for the event - imageBuffer = response.data; - }) - // eslint-disable-next-line no-unused-vars - .catch((error) => { - // Empty - }); - } - if (imageBuffer === undefined) { - // Camera/doorbell image buffer is empty still, so do direct grab from Nest API - let request = { - method: 'get', - url: this.deviceData.nexus_api_http_server_url + '/get_image?uuid=' + this.deviceData.uuid.split('.')[1], - // + '&width=' + snapshotRequestDetails.width, - headers: { - 'User-Agent': USERAGENT, - accept: '*/*', - [this.deviceData.apiAccess.key]: this.deviceData.apiAccess.value + this.deviceData.apiAccess.token, - }, - responseType: 'arraybuffer', - timeout: 3000, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera API snapshot failed with error'); - } - - imageBuffer = response.data; - this.lastSnapshotImage = response.data; - - // Keep this snapshot image cached for a certain period - this.snapshotTimer = clearTimeout(this.snapshotTimer); - this.snapshotTimer = setTimeout(() => { - this.lastSnapshotImage = undefined; - }, SNAPSHOTCACHETIMEOUT); - }) - // eslint-disable-next-line no-unused-vars - .catch((error) => { - // Empty - }); + let response = await this.get({ camera_snapshot: '' }); + if (Buffer.isBuffer(response?.camera_snapshot) === true) { + imageBuffer = response.camera_snapshot; + this.lastSnapshotImage = response.camera_snapshot; + + // Keep this snapshot image cached for a certain period + this.snapshotTimer = clearTimeout(this.snapshotTimer); + this.snapshotTimer = setTimeout(() => { + this.lastSnapshotImage = undefined; + }, SNAPSHOTCACHETIMEOUT); } } @@ -596,7 +559,13 @@ export default class NestCamera extends HomeKitDevice { async handleStreamRequest(request, callback) { // called when HomeKit asks to start/stop/reconfigure a camera/doorbell stream - if (this.NexusStreamer === undefined) { + if (this.streamer === undefined) { + this?.log?.error && + this.log.error( + 'Received request to start live video for "%s" however we do not any associated streaming protocol supported', + this.deviceData.description, + ); + if (typeof callback === 'function') { callback(); // do callback if defined } @@ -689,7 +658,7 @@ export default class NestCamera extends HomeKitDevice { '&pkt_size=188'; } - // Start our ffmpeg streaming process and stream from nexus + // Start our ffmpeg streaming process and stream from our streamer let ffmpegStreaming = child_process.spawn(path.resolve(this.deviceData.ffmpeg.path + '/ffmpeg'), commandLine.split(' '), { env: process.env, stdio: ['pipe', 'pipe', 'pipe', 'pipe'], @@ -844,13 +813,14 @@ export default class NestCamera extends HomeKitDevice { ffmpegAudioTalkback?.stdout ? 'with two-way audio' : '', ); - // Start the stream from nexus - this.NexusStreamer.startLiveStream( - request.sessionID, - ffmpegStreaming.stdin, - ffmpegStreaming?.stdio?.[3] ? ffmpegStreaming.stdio[3] : null, - ffmpegAudioTalkback?.stdout ? ffmpegAudioTalkback.stdout : null, - ); + // Start the appropirate streamer + this.streamer !== undefined && + this.streamer.startLiveStream( + request.sessionID, + ffmpegStreaming.stdin, + ffmpegStreaming?.stdio?.[3] ? ffmpegStreaming.stdio[3] : null, + ffmpegAudioTalkback?.stdout ? ffmpegAudioTalkback.stdout : null, + ); // Store our ffmpeg sessions ffmpegStreaming && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegStreaming); // Store ffmpeg process ID @@ -860,7 +830,7 @@ export default class NestCamera extends HomeKitDevice { } if (request.type === this.hap.StreamRequestTypes.STOP && typeof this.#hkSessions[request.sessionID] === 'object') { - this.NexusStreamer.stopLiveStream(request.sessionID); + this.streamer !== undefined && this.streamer.stopLiveStream(request.sessionID); // Close off any running ffmpeg and/or splitter processes we created if (typeof this.#hkSessions[request.sessionID]?.rtpSplitter?.close === 'function') { @@ -917,7 +887,7 @@ export default class NestCamera extends HomeKitDevice { // 1 = Disabled this.operatingModeService.updateCharacteristic( this.hap.Characteristic.ManuallyDisabled, - deviceData.streaming_enabled === true && deviceData.online === true ? 0 : 1, + deviceData.streaming_enabled === true ? 0 : 1, ); if (deviceData.has_statusled === true && typeof deviceData.statusled_brightness === 'number') { @@ -967,10 +937,8 @@ export default class NestCamera extends HomeKitDevice { this.controller.setSpeakerMuted(deviceData.audio_enabled === false ? true : false); } - if (this?.NexusStreamer !== undefined) { - // Notify the Nexus object of any camera detail updates that it might need to know about - this.NexusStreamer.update(deviceData); - } + // Notify our associated streamers about any data changes + this.streamer !== undefined && this.streamer.update(deviceData); // Process alerts, the most recent alert is first // For HKSV, we're interested motion events @@ -979,17 +947,8 @@ export default class NestCamera extends HomeKitDevice { // Handle motion event // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active if (event.types.includes('motion') === true) { - if (this.motionTimer === undefined) { - this?.log?.info && - this.log.info( - 'Motion detected at "%s" %s', - this.deviceData.description, - this.controller?.recordingManagement?.recordingManagementService !== undefined && - this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === - this.hap.Characteristic.Active.INACTIVE - ? 'but recording is disabled' - : '', - ); + if (this.motionTimer === undefined && (this.deviceData.hksv === false || this.streamer === undefined)) { + this?.log?.info && this.log.info('Motion detected at "%s"', this.deviceData.description); } event.zone_ids.forEach((zoneID) => { @@ -1037,7 +996,7 @@ export default class NestCamera extends HomeKitDevice { if (event.types.includes('person') === true || event.types.includes('face') === true) { if (this.personTimer === undefined) { // We don't have a person cooldown timer running, so we can process the 'person'/'face' event - if (this?.log?.info && this.deviceData.hksv === false) { + if (this?.log?.info && (this.deviceData.hksv === false || this.streamer === undefined)) { // We'll only log a person detected event if HKSV is disabled this.log.info('Person detected at "%s"', this.deviceData.description); } @@ -1045,21 +1004,12 @@ export default class NestCamera extends HomeKitDevice { // Cooldown for person being detected // Start this before we process further this.personTimer = setTimeout(() => { - this.snapshotEvent = undefined; // Clear snapshot event image after timeout this.personTimer = undefined; // No person timer active }, this.deviceData.personCooldown * 1000); - // Check which zone triggered the person alert and update associated motion sensor(s) - this.snapshotEvent = { - type: 'person', - time: event.playback_time, - id: event.id, - done: false, - }; - if (event.types.includes('motion') === false) { // If person/face events doesn't include a motion event, add in here - // This will handle all the motion trigging stuff + // This will handle all the motion triggering stuff event.types.push('motion'); } } diff --git a/src/doorbell.js b/src/doorbell.js index 0722505..5e92c94 100644 --- a/src/doorbell.js +++ b/src/doorbell.js @@ -1,7 +1,7 @@ // Nest Doorbell(s) // Part of homebridge-nest-accfactory // -// Code version 3/9/2024 +// Code version 6/9/2024 // Mark Hulskamp 'use strict'; @@ -92,23 +92,9 @@ export default class NestDoorbell extends NestCamera { // Cooldown for doorbell button being pressed (filters out constant pressing for time period) // Start this before we process further this.doorbellTimer = setTimeout(() => { - this.snapshotEvent = undefined; // Clear snapshot event image after timeout this.doorbellTimer = undefined; // No doorbell timer active }, this.deviceData.doorbellCooldown * 1000); - if (event.types.includes('motion') === false) { - // No motion event with the doorbell alert, add one to trigger HKSV recording if configured - // seems in HomeKit, EventTriggerOption.DOORBELL gets ignored - event.types.push('motion'); - } - - this.snapshotEvent = { - type: 'ring', - time: event.playback_time, - id: event.id, - done: false, - }; - if (deviceData.indoor_chime_enabled === false || deviceData.quiet_time_enabled === true) { // Indoor chime is disabled or quiet time is enabled, so we won't 'ring' the doorbell this?.log?.warn && this.log.warn('Doorbell rung at "%s" but indoor chime is silenced', this.deviceData.description); diff --git a/src/nexusstreamer.js b/src/nexustalk.js similarity index 51% rename from src/nexusstreamer.js rename to src/nexustalk.js index 83968ac..ac54c50 100644 --- a/src/nexusstreamer.js +++ b/src/nexustalk.js @@ -1,11 +1,9 @@ -// nexusstreamer +// NexusTalk // Part of homebridge-nest-accfactory // -// Buffers a single audio/video stream from Nest 'nexus' systems. -// Allows multiple HomeKit devices to connect to the single stream -// for live viewing and/or recording +// Handles connection and data from Nest 'nexus' systems // -// Code version 3/9/2024 +// Code version 6/9/2024 // Mark Hulskamp 'use strict'; @@ -15,16 +13,14 @@ import protoBuf from 'pbf'; // Proto buffer // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers'; -import fs from 'fs'; -import path from 'node:path'; import tls from 'tls'; import crypto from 'crypto'; -import { fileURLToPath } from 'node:url'; + +// Define our modules +import Streamer from './streamer.js'; // Define constants const PINGINTERVAL = 15000; // Ping interval to nexus server while stream active -const CAMERAOFFLINEH264FILE = 'Nest_camera_offline.h264'; // Camera offline H264 frame file -const CAMERAOFFH264FILE = 'Nest_camera_off.h264'; // Camera off H264 frame file const CodecType = { SPEEX: 0, @@ -101,67 +97,24 @@ const ClientType = { WEB: 3, }; -/*const H264NALUnitType = { - STAP_A: 0x18, - FU_A: 0x1c, - NON_IDR: 0x01, - IDR: 0x05, - SEI: 0x06, - SPS: 0x07, - PPS: 0x08, - AUD: 0x09, -};*/ - -const H264NALStartcode = Buffer.from([0x00, 0x00, 0x00, 0x01]); -const AACAudioSilence = Buffer.from([0x21, 0x10, 0x01, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname - -// NeuxsStreamer object -export default class NexusStreamer { - cameraOfflineFrame = undefined; - cameraVideoOffFrame = undefined; - +// nexusTalk object +export default class NexusTalk extends Streamer { token = undefined; tokenType = undefined; - videoEnabled = undefined; - audioEnabled = undefined; - online = undefined; uuid = undefined; - - outputs = {}; // Output streams ie: buffer, live, record - - // Internal data only for this class - #nexusTalk = { - id: undefined, // Session ID - authorised: false, // Have wee been authorised - host: '', // Host to connect to or connected too - socket: null, // TCP socket object - pingTimer: undefined, // Timer object for ping interval - stalledTimer: undefined, // Timer object for no received data - outputTimer: undefined, // Timer for non-block loop stream outputs - packets: [], // Incoming packets - messages: [], // Incoming messages - video: {}, // Video stream details - audio: {}, // Audio stream details - }; + id = undefined; // Session ID + authorised = false; // Have wee been authorised + pingTimer = undefined; // Timer object for ping interval + stalledTimer = undefined; // Timer object for no received data + packets = []; // Incoming packets + messages = []; // Incoming messages + video = {}; // Video stream details + audio = {}; // Audio stream details constructor(deviceData, options) { - // Setup logger object if passed as option - if ( - typeof options?.log?.info === 'function' && - typeof options?.log?.success === 'function' && - typeof options?.log?.warn === 'function' && - typeof options?.log?.error === 'function' && - typeof options?.log?.debug === 'function' - ) { - this.log = options.log; - } + super(deviceData, options); // Store data we need from the device data passed it - this.online = deviceData?.online === true; - this.videoEnabled = deviceData?.streaming_enabled === true; - this.audioEnabled = deviceData?.audio_enabled === true; this.token = deviceData?.apiAccess.token; if (deviceData?.apiAccess?.key === 'Authorization') { this.tokenType = 'google'; @@ -170,84 +123,11 @@ export default class NexusStreamer { this.tokenType = 'nest'; } this.uuid = deviceData?.uuid; - this.#nexusTalk.host = deviceData?.direct_nexustalk_host; // Host we'll connect to + this.host = deviceData?.streaming_host; // Host we'll connect to this.pendingHost = null; this.weDidClose = true; // Flag if we did the socket close gracefully - // Setup location for *.h264 frame files. This can be overriden by a passed in option - let resourcePath = path.resolve(__dirname + '/res'); // Default location for *.h264 files - if ( - typeof options?.resourcePath === 'string' && - options.resourcePath !== '' && - fs.existsSync(path.resolve(options.resourcePath)) === true - ) { - resourcePath = path.resolve(options.resourcePath); - } - - // load buffer for camera offline image in .h264 frame - if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)) === true) { - this.cameraOfflineFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)); - // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router - if (this.cameraOfflineFrame.indexOf(H264NALStartcode) === 0) { - this.cameraOfflineFrame = this.cameraOfflineFrame.subarray(H264NALStartcode.length); - } - } - - // load buffer for camera stream off image in .h264 frame - if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)) === true) { - this.cameraVideoOffFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)); - // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router - if (this.cameraVideoOffFrame.indexOf(H264NALStartcode) === 0) { - this.cameraVideoOffFrame = this.cameraVideoOffFrame.subarray(H264NALStartcode.length); - } - } - - // Start a non-blocking loop for output to the various streams which connect to our streamer object - // This process will also handle the rolling-buffer size we require - // Record streams will always start from the beginning of the buffer (tail) - // Live streams will always start from the end of the buffer (head) - let lastTimeVideo = Date.now(); - this.#nexusTalk.outputTimer = setInterval(() => { - let dateNow = Date.now(); - let outputVideoFrame = dateNow > lastTimeVideo + 90000 / 30; - Object.values(this.outputs).forEach((output) => { - // Monitor for camera going offline and/or video enabled/disabled - // We'll insert the appropriate video frame into the stream - if (this.online === false && this.cameraOfflineFrame !== undefined && outputVideoFrame === true) { - // Camera is offline so feed in our custom h264 frame and AAC silence - output.buffer.push({ type: 'video', time: dateNow, data: this.cameraOfflineFrame }); - output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); - lastTimeVideo = dateNow; - } - if (this.online === true && this.videoEnabled === false && this.cameraVideoOffFrame !== undefined && outputVideoFrame === true) { - // Camera video is turned off so feed in our custom h264 frame and AAC silence - output.buffer.push({ type: 'video', time: dateNow, data: this.cameraVideoOffFrame }); - output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); - lastTimeVideo = dateNow; - } - - // Keep our 'main' rolling buffer under a certain size - // Live/record buffers will always reduce in length in the next section - // <---- maybe make this time based x time since firts packet in buffer? - if (output.type === 'buffer' && output.buffer.length > 1500) { - output.buffer.shift(); - } - - // Output the packet data to any streams 'live' or 'recording' streams - if (output.type === 'live' || output.type === 'record') { - let packet = output.buffer.shift(); - if (packet?.type === 'video' && typeof output?.video?.write === 'function') { - // H264 NAL Units '0001' are required to be added to beginning of any video data we output - output.video.write(Buffer.concat([H264NALStartcode, packet.data])); - } - if (packet?.type === 'audio' && typeof output?.audio?.write === 'function') { - output.audio.write(packet.data); - } - } - }); - }, 0); - // If specified option to start buffering, kick off if (typeof options?.buffer === 'boolean' && options.buffer === true) { this.startBuffering(); @@ -255,202 +135,17 @@ export default class NexusStreamer { } // Class functions - isBuffering() { - return this.outputs?.buffer !== undefined; - } - - startBuffering() { - if (this.outputs?.buffer === undefined) { - // No active buffer session, start connection to nexus - if (this.#nexusTalk.socket === null && typeof this.#nexusTalk.host === 'string' && this.#nexusTalk.host !== '') { - this.#connect(this.#nexusTalk.host); - this?.log?.debug && this.log.debug('Started buffering from "%s"', this.#nexusTalk.host); - } - - this.outputs.buffer = { - type: 'buffer', - buffer: [], - }; - } - } - - startLiveStream(sessionID, videoStream, audioStream, talkbackStream) { - // Setup error catching for video/audio/talkback streams - if (videoStream !== null && typeof videoStream === 'object') { - videoStream.on('error', () => { - // EPIPE errors?? - }); - } - - if (audioStream !== null && typeof audioStream === 'object') { - audioStream.on('error', () => { - // EPIPE errors?? - }); - } - - if (talkbackStream !== null && typeof talkbackStream === 'object') { - let talkbackTimeout = undefined; - - talkbackStream.on('error', () => { - // EPIPE errors?? - }); - - talkbackStream.on('data', (data) => { - // Received audio data to send onto nexus for output to camera/doorbell - this.#AudioPayload(data); - - talkbackTimeout = clearTimeout(talkbackTimeout); - talkbackTimeout = setTimeout(() => { - // no audio received in 500ms, so mark end of stream - this.#AudioPayload(Buffer.alloc(0)); - }, 500); - }); - } - - if (this.#nexusTalk.socket === null && typeof this.#nexusTalk.host === 'string' && this.#nexusTalk.host !== '') { - // We do not have an active socket connection, so startup connection to nexus - this.#connect(this.#nexusTalk.host); - } - - // Add video/audio streams for our output loop to handle outputting to - this.outputs[sessionID] = { - type: 'live', - video: videoStream, - audio: audioStream, - talk: talkbackStream, - buffer: [], - }; - - // finally, we've started live stream - this?.log?.debug && - this.log.debug( - 'Started live stream from "%s" %s and sesssion id of "%s"', - this.#nexusTalk.host, - talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio' : '', - sessionID, - ); - } - - startRecordStream(sessionID, videoStream, audioStream) { - // Setup error catching for video/audio streams - if (videoStream !== null && typeof videoStream === 'object') { - videoStream.on('error', () => { - // EPIPE errors?? - }); - } - - if (audioStream !== null && typeof audioStream === 'object') { - audioStream.on('error', () => { - // EPIPE errors?? - }); - } - - if (this.#nexusTalk.socket === null && typeof this.#nexusTalk.host === 'string' && this.#nexusTalk.host !== '') { - // We do not have an active socket connection, so startup connection to nexus - this.#connect(this.#nexusTalk.host); - } - - // Add video/audio streams for our output loop to handle outputting to - this.outputs[sessionID] = { - type: 'record', - video: videoStream, - audio: audioStream, - // eslint-disable-next-line no-undef - buffer: this.outputs?.buffer?.buffer !== undefined ? structuredClone(this.outputs.buffer.buffer) : [], - }; - - // Finally we've started the recording stream - this?.log?.debug && this.log.debug('Started recording stream from "%s" with sesison id of "%s"', this.#nexusTalk.host, sessionID); - } - - stopRecordStream(sessionID) { - // Request to stop a recording stream - if (typeof this.outputs[sessionID] === 'object') { - this?.log?.debug && this.log.debug('Stopped recording stream from "%s"', this.#nexusTalk.host); - delete this.outputs[sessionID]; - } - - // If we have no more output streams active, we'll close the connection to nexus - if (Object.keys(this.outputs).length === 0) { - this.#close(true); - } - } - - stopLiveStream(sessionID) { - // Request to stop an active live stream - if (typeof this.outputs[sessionID] === 'object') { - this?.log?.debug && this.log.debug('Stopped live stream from "%s"', this.#nexusTalk.host); - delete this.outputs[sessionID]; - } - - // If we have no more output streams active, we'll close the connection to nexus - if (Object.keys(this.outputs).length === 0) { - this.#close(true); - } - } - - stopBuffering() { - if (this.outputs?.buffer !== undefined) { - this?.log?.debug && this.log.debug('Stopped buffering from "%s"', this.#nexusTalk.host); - delete this.outputs.buffer; - } - - // If we have no more output streams active, we'll close the connection to nexus - if (Object.keys(this.outputs).length === 0) { - this.#close(true); - } - } - - update(deviceData) { - if (typeof deviceData !== 'object') { - return; - } - - if (deviceData.apiAccess.token !== this.token) { - // access token has changed so re-authorise - this.token = deviceData.apiAccess.token; - - if (this.#nexusTalk.socket !== null) { - this.#Authenticate(true); // Update authorisation only if connected - } - } - - this.online = deviceData?.online === true; - this.videoEnabled = deviceData?.streaming_enabled === true; - this.audioEnabled = deviceData?.audio_enabled === true; - this.token = deviceData?.apiAccess.token; - this.#nexusTalk.host = deviceData?.direct_nexustalk_host; // Host we'll connect to - - if (this.online !== deviceData.online || this.videoEnabled !== deviceData.streaming_enabled) { - // Online status or streaming status has changed has changed - this.online = deviceData?.online === true; - this.videoEnabled = deviceData?.streaming_enabled === true; - this.#nexusTalk.host = deviceData?.direct_nexustalk_host; // Host we'll connect to - if (this.online === false || this.videoEnabled === false) { - this.#close(true); // as offline or streaming not enabled, close socket - } - if (this.online === true && this.videoEnabled === true) { - this.#connect(this.#nexusTalk.host); // Connect to Nexus for stream - } - } - - if (this.#nexusTalk.host !== deviceData.direct_nexustalk_host) { - this.#nexusTalk.host = deviceData.direct_nexustalk_host; - this?.log?.debug && this.log.debug('Updated Nexusstreamer host "%s"', deviceData.direct_nexustalk_host); - } - } - - #connect(host) { + connect(host) { // Clear any timers we have running - this.#nexusTalk.pingTimer = clearInterval(this.#nexusTalk.pingTimer); - this.#nexusTalk.stalledTimer = clearInterval(this.#nexusTalk.stalledTimer); + this.pingTimer = clearInterval(this.pingTimer); + this.stalledTimer = clearInterval(this.stalledTimer); - this.#nexusTalk.id = undefined; // No session ID yet + this.id = undefined; // No session ID yet if (this.online === true && this.videoEnabled === true) { if (typeof host === 'undefined' || host === null) { // No host parameter passed in, so we'll set this to our internally stored host - host = this.#nexusTalk.host; + host = this.host; } if (this.pendingHost !== null) { @@ -460,66 +155,95 @@ export default class NexusStreamer { this?.log?.debug && this.log.debug('Starting connection to "%s"', host); - this.#nexusTalk.socket = tls.connect({ host: host, port: 1443 }, () => { + this.socket = tls.connect({ host: host, port: 1443 }, () => { // Opened connection to Nexus server, so now need to authenticate ourselves this?.log?.debug && this.log.debug('Connection established to "%s"', host); - this.#nexusTalk.socket.setKeepAlive(true); // Keep socket connection alive - this.#nexusTalk.host = host; // update internal host name since we've connected + this.socket.setKeepAlive(true); // Keep socket connection alive + this.host = host; // update internal host name since we've connected this.#Authenticate(false); }); - this.#nexusTalk.socket.on('error', () => {}); + this.socket.on('error', () => {}); - this.#nexusTalk.socket.on('end', () => {}); + this.socket.on('end', () => {}); - this.#nexusTalk.socket.on('data', (data) => { + this.socket.on('data', (data) => { this.#handleNexusData(data); }); - this.#nexusTalk.socket.on('close', (hadError) => { + this.socket.on('close', (hadError) => { if (hadError === true) { // } let normalClose = this.weDidClose; // Cache this, so can reset it below before we take action - this.#nexusTalk.stalledTimer = clearTimeout(this.#nexusTalk.stalledTimer); // Clear stalled timer - this.#nexusTalk.pingTimer = clearInterval(this.#nexusTalk.pingTimer); // Clear ping timer - this.#nexusTalk.authorised = false; // Since connection close, we can't be authorised anymore - this.#nexusTalk.socket = null; // Clear socket object - this.#nexusTalk.id = undefined; // Not an active session anymore + this.stalledTimer = clearTimeout(this.stalledTimer); // Clear stalled timer + this.pingTimer = clearInterval(this.pingTimer); // Clear ping timer + this.authorised = false; // Since connection close, we can't be authorised anymore + this.socket = null; // Clear socket object + this.id = undefined; // Not an active session anymore this.weDidClose = false; // Reset closed flag this?.log?.debug && this.log.debug('Connection closed to "%s"', host); - if (normalClose === false && Object.keys(this.outputs).length > 0) { + if (normalClose === false && this.haveOutputs() === true) { // We still have either active buffering occuring or output streams running // so attempt to restart connection to existing host - this.#connect(host); + this.connect(host); } }); } } - #close(sendStop) { + close(stopStreamFirst) { // Close an authenicated socket stream gracefully - if (this.#nexusTalk.socket !== null) { - if (sendStop === true) { + if (this.socket !== null) { + if (stopStreamFirst === true) { // Send a notifcation to nexus we're finished playback this.#stopNexusData(); } - this.#nexusTalk.socket.destroy(); + this.socket.destroy(); } - this.#nexusTalk.socket = null; - this.#nexusTalk.id = undefined; // Not an active session anymore - this.#nexusTalk.packets = []; - this.#nexusTalk.messages = []; + this.socket = null; + this.id = undefined; // Not an active session anymore + this.packets = []; + this.messages = []; this.weDidClose = true; // Flag we did the socket close } + update(deviceData) { + if (typeof deviceData !== 'object') { + return; + } + + if (deviceData.apiAccess.token !== this.token) { + // access token has changed so re-authorise + this.token = deviceData.apiAccess.token; + + if (this.socket !== null) { + this.#Authenticate(true); // Update authorisation only if connected + } + } + + // Let our parent handle the remaining updates + super.update(deviceData); + } + + talkingAudio(talkingData) { + // Encode audio packet for sending to camera + let audioBuffer = new protoBuf(); + audioBuffer.writeBytesField(1, talkingData); // audio data + audioBuffer.writeVarintField(2, this.id); // session ID + audioBuffer.writeVarintField(3, CodecType.SPEEX); // codec + audioBuffer.writeVarintField(4, 16000); // sample rate, 16k + //audioBuffer.writeVarintField(5, ????); // Latency measure tag. What does this do? + this.#sendMessage(PacketType.AUDIO_PAYLOAD, audioBuffer.finish()); + } + #startNexusData() { if (this.videoEnabled === false || this.online === false) { return; @@ -548,18 +272,14 @@ export default class NexusStreamer { #stopNexusData() { let stopBuffer = new protoBuf(); - stopBuffer.writeVarintField(1, this.#nexusTalk.id); // Session ID + stopBuffer.writeVarintField(1, this.id); // Session ID this.#sendMessage(PacketType.STOP_PLAYBACK, stopBuffer.finish()); } #sendMessage(type, data) { - if ( - this.#nexusTalk.socket === null || - this.#nexusTalk.socket.readyState !== 'open' || - (type !== PacketType.HELLO && this.#nexusTalk.authorised === false) - ) { + if (this.socket === null || this.socket.readyState !== 'open' || (type !== PacketType.HELLO && this.authorised === false)) { // We're not connect and/or authorised yet, so 'cache' message for processing once this occurs - this.#nexusTalk.messages.push({ type: type, data: data }); + this.messages.push({ type: type, data: data }); return; } @@ -576,7 +296,7 @@ export default class NexusStreamer { } // write our composed message out to the socket back to NexusTalk - this.#nexusTalk.socket.write(Buffer.concat([header, Buffer.from(data)]), () => { + this.socket.write(Buffer.concat([header, Buffer.from(data)]), () => { // Message sent. Don't do anything? }); } @@ -586,7 +306,7 @@ export default class NexusStreamer { let tokenBuffer = new protoBuf(); let helloBuffer = new protoBuf(); - this.#nexusTalk.authorised = false; // We're nolonger authorised + this.authorised = false; // We're nolonger authorised if (this.tokenType === 'nest') { tokenBuffer.writeStringField(1, this.token); // Tag 1, session token, Nest auth accounts @@ -598,11 +318,11 @@ export default class NexusStreamer { } if (typeof reauthorise === 'boolean' && reauthorise === true) { // Request to re-authorise only - this?.log?.debug && this.log.debug('Re-authentication requested to "%s"', this.#nexusTalk.host); + this?.log?.debug && this.log.debug('Re-authentication requested to "%s"', this.host); this.#sendMessage(PacketType.AUTHORIZE_REQUEST, tokenBuffer.finish()); } else { // This isn't a re-authorise request, so perform 'Hello' packet - this?.log?.debug && this.log.debug('Performing authentication to "%s"', this.#nexusTalk.host); + this?.log?.debug && this.log.debug('Performing authentication to "%s"', this.host); helloBuffer.writeVarintField(1, ProtocolVersion.VERSION_3); helloBuffer.writeStringField(2, this.uuid.split('.')[1]); // UUID should be 'quartz.xxxxxx'. We want the xxxxxx part helloBuffer.writeBooleanField(3, false); // Doesnt required a connected camera @@ -613,17 +333,6 @@ export default class NexusStreamer { } } - #AudioPayload(payload) { - // Encode audio packet for sending to camera - let audioBuffer = new protoBuf(); - audioBuffer.writeBytesField(1, payload); // audio data - audioBuffer.writeVarintField(2, this.#nexusTalk.id); // session ID - audioBuffer.writeVarintField(3, CodecType.SPEEX); // codec - audioBuffer.writeVarintField(4, 16000); // sample rate, 16k - //audioBuffer.writeVarintField(5, ????); // Latency measure tag. What does this do? - this.#sendMessage(PacketType.AUDIO_PAYLOAD, audioBuffer.finish()); - } - #handleRedirect(payload) { let redirectToHost = undefined; if (typeof payload === 'object') { @@ -652,14 +361,14 @@ export default class NexusStreamer { return; } - this?.log?.debug && this.log.debug('Redirect requested from "%s" to "%s"', this.#nexusTalk.host, redirectToHost); + this?.log?.debug && this.log.debug('Redirect requested from "%s" to "%s"', this.host, redirectToHost); // Setup listener for socket close event. Once socket is closed, we'll perform the redirect - this.#nexusTalk.socket && - this.#nexusTalk.socket.on('close', () => { - this.#connect(redirectToHost); // Connect to new host + this.socket && + this.socket.on('close', () => { + this.connect(redirectToHost); // Connect to new host }); - this.#close(true); // Close existing socket + this.close(true); // Close existing socket } #handlePlaybackBegin(payload) { @@ -723,7 +432,7 @@ export default class NexusStreamer { packet.channels.forEach((stream) => { // Find which channels match our video and audio streams if (stream.codec_type === CodecType.H264) { - this.#nexusTalk.video = { + this.video = { channel_id: stream.channel_id, start_time: Date.now() + stream.start_time, sample_rate: stream.sample_rate, @@ -731,7 +440,7 @@ export default class NexusStreamer { }; } if (stream.codec_type === CodecType.AAC || stream.codec_type === CodecType.OPUS || stream.codec_type === CodecType.SPEEX) { - this.#nexusTalk.audio = { + this.audio = { channel_id: stream.channel_id, start_time: Date.now() + stream.start_time, sample_rate: stream.sample_rate, @@ -741,11 +450,11 @@ export default class NexusStreamer { }); // Since this is the beginning of playback, clear any active buffers contents - this.#nexusTalk.id = packet.session_id; - this.#nexusTalk.packets = []; - this.#nexusTalk.messages = []; + this.id = packet.session_id; + this.packets = []; + this.messages = []; - this?.log?.debug && this.log.debug('Playback started from "%s" with session ID "%s"', this.#nexusTalk.host, this.#nexusTalk.id); + this?.log?.debug && this.log.debug('Playback started from "%s" with session ID "%s"', this.host, this.id); } #handlePlaybackPacket(payload) { @@ -818,39 +527,29 @@ export default class NexusStreamer { // Setup up a timeout to monitor for no packets recieved in a certain period // If its trigger, we'll attempt to restart the stream and/or connection // <-- testing to see how often this occurs first - this.#nexusTalk.stalledTimer = clearTimeout(this.#nexusTalk.stalledTimer); - this.#nexusTalk.stalledTimer = setTimeout(() => { + this.stalledTimer = clearTimeout(this.stalledTimer); + this.stalledTimer = setTimeout(() => { this?.log?.debug && this.log.debug('We have not received any data from nexus in the past "%s" seconds. Attempting restart', 8); // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection - this.#nexusTalk.socket && - this.#nexusTalk.socket.on('close', () => { - this.#connect(this.#nexusTalk.host); // try reconnection + this.socket && + this.socket.on('close', () => { + this.connect(this.host); // try reconnection }); - this.#close(false); // Close existing socket + this.close(false); // Close existing socket }, 8000); - Object.values(this.outputs).forEach((output) => { - // Handle video packet - if (packet.channel_id === this.#nexusTalk.video.channel_id) { - this.#nexusTalk.video.timestamp_delta += packet.timestamp_delta; - output.buffer.push({ - type: 'video', - time: this.#nexusTalk.video.start_time + this.#nexusTalk.video.timestamp_delta, - data: packet.payload, - }); - } + // Handle video packet + if (packet.channel_id === this.video.channel_id) { + this.video.timestamp_delta += packet.timestamp_delta; + this.addToOutput('video', this.video.start_time + this.video.timestamp_delta, packet.payload); + } - // Handle audio packet - if (packet.channel_id === this.#nexusTalk.audio.channel_id) { - this.#nexusTalk.audio.timestamp_delta += packet.timestamp_delta; - output.buffer.push({ - type: 'audio', - time: this.#nexusTalk.audio.start_time + this.#nexusTalk.audio.timestamp_delta, - data: packet.payload, - }); - } - }); + // Handle audio packet + if (packet.channel_id === this.audio.channel_id) { + this.audio.timestamp_delta += packet.timestamp_delta; + this.addToOutput('audio', this.audio.start_time + this.audio.timestamp_delta, packet.payload); + } } #handlePlaybackEnd(payload) { @@ -867,22 +566,21 @@ export default class NexusStreamer { { session_id: 0, reason: 0 }, ); - if (this.#nexusTalk.id !== null && packet.reason === 0) { + if (this.id !== null && packet.reason === 0) { // Normal playback ended ie: when we stopped playback - this?.log?.debug && this.log.debug('Playback ended on "%s"', this.#nexusTalk.host); + this?.log?.debug && this.log.debug('Playback ended on "%s"', this.host); } if (packet.reason !== 0) { // Error during playback, so we'll attempt to restart by reconnection to host - this?.log?.debug && - this.log.debug('Playback ended on "%s" with error "%s". Attempting reconnection', this.#nexusTalk.host, packet.reason); + this?.log?.debug && this.log.debug('Playback ended on "%s" with error "%s". Attempting reconnection', this.host, packet.reason); // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection - this.#nexusTalk.socket && - this.#nexusTalk.socket.on('close', () => { - this.#connect(this.#nexusTalk.host); // try reconnection to existing host + this.socket && + this.socket.on('close', () => { + this.connect(this.host); // try reconnection to existing host }); - this.#close(false); // Close existing socket + this.close(false); // Close existing socket } } @@ -957,25 +655,25 @@ export default class NexusStreamer { #handleNexusData(data) { // Process the rawdata from our socket connection and convert into nexus packets to take action against - this.#nexusTalk.packets = this.#nexusTalk.packets.length === 0 ? data : Buffer.concat([this.#nexusTalk.packets, data]); + this.packets = this.packets.length === 0 ? data : Buffer.concat([this.packets, data]); - while (this.#nexusTalk.packets.length >= 3) { + while (this.packets.length >= 3) { let headerSize = 3; - let packetType = this.#nexusTalk.packets.readUInt8(0); - let packetSize = this.#nexusTalk.packets.readUInt16BE(1); + let packetType = this.packets.readUInt8(0); + let packetSize = this.packets.readUInt16BE(1); if (packetType === PacketType.LONG_PLAYBACK_PACKET) { headerSize = 5; - packetSize = this.#nexusTalk.packets.readUInt32BE(1); + packetSize = this.packets.readUInt32BE(1); } - if (this.#nexusTalk.packets.length < headerSize + packetSize) { + if (this.packets.length < headerSize + packetSize) { // We dont have enough data in the buffer yet to process the full packet // so, exit loop and await more data break; } - let protoBufPayload = new protoBuf(this.#nexusTalk.packets.slice(headerSize, headerSize + packetSize)); + let protoBufPayload = new protoBuf(this.packets.slice(headerSize, headerSize + packetSize)); switch (packetType) { case PacketType.PING: { break; @@ -983,14 +681,14 @@ export default class NexusStreamer { case PacketType.OK: { // process any pending messages we have stored - this.#nexusTalk.authorised = true; // OK message, means we're connected and authorised to Nexus - for (let message = this.#nexusTalk.messages.shift(); message; message = this.#nexusTalk.messages.shift()) { + this.authorised = true; // OK message, means we're connected and authorised to Nexus + for (let message = this.messages.shift(); message; message = this.messages.shift()) { this.#sendMessage(message.type, message.data); } // Periodically send PING message to keep stream alive - this.#nexusTalk.pingTimer = clearInterval(this.#nexusTalk.pingTimer); - this.#nexusTalk.pingTimer = setInterval(() => { + this.pingTimer = clearInterval(this.pingTimer); + this.pingTimer = setInterval(() => { this.#sendMessage(PacketType.PING, Buffer.alloc(0)); }, PINGINTERVAL); @@ -1037,7 +735,7 @@ export default class NexusStreamer { } // Remove the section of data we've just processed from our pending buffer - this.#nexusTalk.packets = this.#nexusTalk.packets.slice(headerSize + packetSize); + this.packets = this.packets.slice(headerSize + packetSize); } } } diff --git a/src/streamer.js b/src/streamer.js new file mode 100644 index 0000000..70f0004 --- /dev/null +++ b/src/streamer.js @@ -0,0 +1,344 @@ +// streamer +// Part of homebridge-nest-accfactory +// +// This is the base class for all Camera/Doorbell streaming +// +// Buffers a single audio/video stream which allows multiple HomeKit devices to connect to the single stream +// for live viewing and/or recording +// +// The following functions should be overriden in your class which extends this +// +// streamer.connect(host) +// streamer.close(stopStreamFirst) +// streamer.talkingAudio(talkingData) +// streamer.update(deviceData) <- call super after +// +// Code version 6/9/2024 +// Mark Hulskamp +'use strict'; + +// Define nodejs module requirements +import { Buffer } from 'node:buffer'; +import { setInterval, setTimeout, clearTimeout } from 'node:timers'; +import fs from 'fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Define constants +const CAMERAOFFLINEH264FILE = 'Nest_camera_offline.h264'; // Camera offline H264 frame file +const CAMERAOFFH264FILE = 'Nest_camera_off.h264'; // Camera off H264 frame file + +const H264NALStartcode = Buffer.from([0x00, 0x00, 0x00, 0x01]); +const AACAudioSilence = Buffer.from([0x21, 0x10, 0x01, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname + +// Streamer object +export default class Streamer { + cameraOfflineFrame = undefined; + cameraVideoOffFrame = undefined; + videoEnabled = undefined; + audioEnabled = undefined; + online = undefined; + host = ''; // Host to connect to or connected too + socket = null; // TCP socket object + + // Internal data only for this class + #outputTimer = undefined; // Timer for non-blocking loop to stream output data + #outputs = {}; // Output streams ie: buffer, live, record + + constructor(deviceData, options) { + // Setup logger object if passed as option + if ( + typeof options?.log?.info === 'function' && + typeof options?.log?.success === 'function' && + typeof options?.log?.warn === 'function' && + typeof options?.log?.error === 'function' && + typeof options?.log?.debug === 'function' + ) { + this.log = options.log; + } + + // Store data we need from the device data passed it + this.online = deviceData?.online === true; + this.videoEnabled = deviceData?.streaming_enabled === true; + this.audioEnabled = deviceData?.audio_enabled === true; + + // Setup location for *.h264 frame files. This can be overriden by a passed in option + let resourcePath = path.resolve(__dirname + '/res'); // Default location for *.h264 files + if ( + typeof options?.resourcePath === 'string' && + options.resourcePath !== '' && + fs.existsSync(path.resolve(options.resourcePath)) === true + ) { + resourcePath = path.resolve(options.resourcePath); + } + + // load buffer for camera offline image in .h264 frame + if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)) === true) { + this.cameraOfflineFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)); + // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router + if (this.cameraOfflineFrame.indexOf(H264NALStartcode) === 0) { + this.cameraOfflineFrame = this.cameraOfflineFrame.subarray(H264NALStartcode.length); + } + } + + // load buffer for camera stream off image in .h264 frame + if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)) === true) { + this.cameraVideoOffFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)); + // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router + if (this.cameraVideoOffFrame.indexOf(H264NALStartcode) === 0) { + this.cameraVideoOffFrame = this.cameraVideoOffFrame.subarray(H264NALStartcode.length); + } + } + + // Start a non-blocking loop for output to the various streams which connect to our streamer object + // This process will also handle the rolling-buffer size we require + // Record streams will always start from the beginning of the buffer (tail) + // Live streams will always start from the end of the buffer (head) + let lastTimeVideo = Date.now(); + this.#outputTimer = setInterval(() => { + let dateNow = Date.now(); + let outputVideoFrame = dateNow > lastTimeVideo + 90000 / 30; // 30 or 15 fps? + Object.values(this.#outputs).forEach((output) => { + // Monitor for camera going offline and/or video enabled/disabled + // We'll insert the appropriate video frame into the stream + if (this.online === false && this.cameraOfflineFrame !== undefined && outputVideoFrame === true) { + // Camera is offline so feed in our custom h264 frame and AAC silence + output.buffer.push({ type: 'video', time: dateNow, data: this.cameraOfflineFrame }); + output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); + lastTimeVideo = dateNow; + } + if (this.online === true && this.videoEnabled === false && this.cameraVideoOffFrame !== undefined && outputVideoFrame === true) { + // Camera video is turned off so feed in our custom h264 frame and AAC silence + output.buffer.push({ type: 'video', time: dateNow, data: this.cameraVideoOffFrame }); + output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); + lastTimeVideo = dateNow; + } + + // Keep our 'main' rolling buffer under a certain size + // Live/record buffers will always reduce in length in the next section + // <---- maybe make this time based x time since first packet in buffer? + if (output.type === 'buffer' && output.buffer.length > 1250) { + output.buffer.shift(); + } + + // Output the packet data to any streams 'live' or 'recording' streams + if (output.type === 'live' || output.type === 'record') { + let packet = output.buffer.shift(); + if (packet?.type === 'video' && typeof output?.video?.write === 'function') { + // H264 NAL Units '0001' are required to be added to beginning of any video data we output + // If this is missing, add on beginning of data packet + if (packet.data.indexOf(H264NALStartcode) !== 0) { + packet.data = Buffer.concat([H264NALStartcode, packet.data]); + } + output.video.write(packet.data); + } + if (packet?.type === 'audio' && typeof output?.audio?.write === 'function') { + output.audio.write(packet.data); + } + } + }); + }, 0); + } + + // Class functions + isBuffering() { + return this.#outputs?.buffer !== undefined; + } + + startBuffering() { + if (this.#outputs?.buffer === undefined) { + // No active buffer session, start connection to streamer + if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + if (typeof this.connect === 'function') { + this.connect(this.host); + this?.log?.debug && this.log.debug('Started buffering from "%s"', this.host); + } + } + + this.#outputs.buffer = { + type: 'buffer', + buffer: [], + }; + } + } + + startLiveStream(sessionID, videoStream, audioStream, talkbackStream) { + // Setup error catching for video/audio/talkback streams + if (videoStream !== null && typeof videoStream === 'object') { + videoStream.on('error', () => { + // EPIPE errors?? + }); + } + + if (audioStream !== null && typeof audioStream === 'object') { + audioStream.on('error', () => { + // EPIPE errors?? + }); + } + + if (talkbackStream !== null && typeof talkbackStream === 'object') { + let talkbackTimeout = undefined; + + talkbackStream.on('error', () => { + // EPIPE errors?? + }); + + talkbackStream.on('data', (data) => { + // Received audio data to send onto camera/doorbell for output + if (typeof this.talkingAudio === 'function') { + this.talkingAudio(data); + + talkbackTimeout = clearTimeout(talkbackTimeout); + talkbackTimeout = setTimeout(() => { + // no audio received in 500ms, so mark end of stream + this.talkingAudio(Buffer.alloc(0)); + }, 500); + } + }); + } + + if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + // We do not have an active socket connection, so startup connection to host + if (typeof this.connect === 'function') { + this.connect(this.host); + } + } + + // Add video/audio streams for our output loop to handle outputting to + this.#outputs[sessionID] = { + type: 'live', + video: videoStream, + audio: audioStream, + talk: talkbackStream, + buffer: [], + }; + + // finally, we've started live stream + this?.log?.debug && + this.log.debug( + 'Started live stream from "%s" %s and sesssion id of "%s"', + this.host, + talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio' : '', + sessionID, + ); + } + + startRecordStream(sessionID, videoStream, audioStream) { + // Setup error catching for video/audio streams + if (videoStream !== null && typeof videoStream === 'object') { + videoStream.on('error', () => { + // EPIPE errors?? + }); + } + + if (audioStream !== null && typeof audioStream === 'object') { + audioStream.on('error', () => { + // EPIPE errors?? + }); + } + + if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + // We do not have an active socket connection, so startup connection to host + if (typeof this.connect === 'function') { + this.connect(this.host); + } + } + + // Add video/audio streams for our output loop to handle outputting to + this.#outputs[sessionID] = { + type: 'record', + video: videoStream, + audio: audioStream, + // eslint-disable-next-line no-undef + buffer: this.#outputs?.buffer?.buffer !== undefined ? structuredClone(this.#outputs.buffer.buffer) : [], + }; + + // Finally we've started the recording stream + this?.log?.debug && this.log.debug('Started recording stream from "%s" with sesison id of "%s"', this.host, sessionID); + } + + stopRecordStream(sessionID) { + // Request to stop a recording stream + if (typeof this.#outputs[sessionID] === 'object') { + this?.log?.debug && this.log.debug('Stopped recording stream from "%s"', this.host); + delete this.#outputs[sessionID]; + } + + // If we have no more output streams active, we'll close the connection to host + if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { + this.close(true); + } + } + + stopLiveStream(sessionID) { + // Request to stop an active live stream + if (typeof this.#outputs[sessionID] === 'object') { + this?.log?.debug && this.log.debug('Stopped live stream from "%s"', this.host); + delete this.#outputs[sessionID]; + } + + // If we have no more output streams active, we'll close the connection to host + if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { + this.close(true); + } + } + + stopBuffering() { + if (this.#outputs?.buffer !== undefined) { + this?.log?.debug && this.log.debug('Stopped buffering from "%s"', this.host); + delete this.#outputs.buffer; + } + + // If we have no more output streams active, we'll close the connection to host + if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { + this.close(true); + } + } + + update(deviceData) { + if (typeof deviceData !== 'object') { + return; + } + + this.online = deviceData?.online === true; + this.videoEnabled = deviceData?.streaming_enabled === true; + this.audioEnabled = deviceData?.audio_enabled === true; + + if (this.host !== deviceData.streaming_host) { + this.host = deviceData.streaming_host; + this?.log?.debug && this.log.debug('New streaming host has requested a new host "%s" for connection', this.host); + } + + if (this.online !== deviceData.online || this.videoEnabled !== deviceData.streaming_enabled) { + // Online status or streaming status has changed has changed + this.online = deviceData?.online === true; + this.videoEnabled = deviceData?.streaming_enabled === true; + if ((this.online === false || this.videoEnabled === false) && typeof this.close === 'function') { + this.close(true); // as offline or streaming not enabled, close socket + } + if (this.online === true && this.videoEnabled === true && typeof this.connect === 'function') { + this.connect(this.host); // Connect to host for stream + } + } + } + + addToOutput(type, time, data) { + if (typeof type !== 'string' || type === '' || typeof time !== 'number' || time === 0) { + return; + } + + Object.values(this.#outputs).forEach((output) => { + output.buffer.push({ + type: type, + time: time, + data: data, + }); + }); + } + + haveOutputs() { + return Object.keys(this.#outputs).length > 0; + } +} diff --git a/src/system.js b/src/system.js index 66d3cb9..206a995 100644 --- a/src/system.js +++ b/src/system.js @@ -1,8 +1,7 @@ -/* eslint-disable @stylistic/indent */ // Nest System communications // Part of homebridge-nest-accfactory // -// Code version 3/9/2024 +// Code version 7/9/2024 // Mark Hulskamp 'use strict'; @@ -34,7 +33,7 @@ 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.75.0 (iOScom.nestlabs.jasper.release) os=17.4.1'; // User Agent string +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 @@ -58,8 +57,10 @@ export default class NestAccfactory { PROTOBUF: 'PROTOBUF', // From the protobuf API }; - static GoogleConnection = 'google'; - static NestConnection = 'nest'; + static GoogleConnection = 'google'; // Google account connection + static NestConnection = 'nest'; // Nest account connection + static SDMConnection = 'sdm'; // NOT coded, but here for future reference + static HomeFoyerConnection = 'foyer'; // Google Home foyer connection cachedAccessories = []; // Track restored cached accessories @@ -122,7 +123,7 @@ export default class NestAccfactory { if (fs.existsSync(path.resolve(this.config.options.ffmpeg.path + '/ffmpeg')) === false) { if (this?.log?.warn) { this.log.warn('No ffmpeg binary found in "%s"', this.config.options.ffmpeg.path); - this.log.warn('Streaming and of recording video will be unavailable for cameras/doorbells'); + 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 @@ -150,7 +151,8 @@ export default class NestAccfactory { this.config.options.ffmpeg.libx264 === false || this.config.options.ffmpeg.libfdk_aac === false ) { - this?.log?.warn && this.log.warn('ffmpeg binary in "%s" does not meet the minimum requirements', this.config.options.ffmpeg.path); + this?.log?.warn && + this.log.warn('ffmpeg binary in "%s" does not meet the minimum support requirements', this.config.options.ffmpeg.path); if (this.config.options.ffmpeg.version.replace(/\./gi, '') < parseFloat(FFMPEGVERSION.toString().replace(/\./gi, ''))) { this?.log?.warn && this.log.warn( @@ -195,6 +197,8 @@ export default class NestAccfactory { this.api.on('shutdown', async () => { // We got notified that Homebridge is shutting down. Perform cleanup?? + this.#eventEmitter.removeAllListeners(HomeKitDevice.SET); + this.#eventEmitter.removeAllListeners(HomeKitDevice.GET); }); } } @@ -209,31 +213,21 @@ export default class NestAccfactory { async discoverDevices() { await this.#connect(); - if (typeof this.#connections?.nest === 'object') { + if (this.#connections?.nest !== undefined) { // We have a 'Nest' connected account, so process accordingly - this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => { - this.#set(NestAccfactory.NestConnection, deviceUUID, values); - }); - this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => { - this.#get(NestAccfactory.NestConnection, deviceUUID, values); - }); - this.#subscribeREST(NestAccfactory.NestConnection, false); this.#subscribeProtobuf(NestAccfactory.NestConnection); } - if (typeof this.#connections?.google === 'object') { + if (this.#connections?.google !== undefined) { // We have a 'Google' connected account, so process accordingly - this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => { - this.#set(NestAccfactory.GoogleConnection, deviceUUID, values); - }); - this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => { - this.#get(NestAccfactory.GoogleConnection, deviceUUID, values); - }); - this.#subscribeREST(NestAccfactory.GoogleConnection, false); this.#subscribeProtobuf(NestAccfactory.GoogleConnection); } + + // Setup event listeners for set/get calls from devices + this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => this.#set(deviceUUID, values)); + this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => this.#get(deviceUUID, values)); } async #connect() { @@ -280,6 +274,7 @@ export default class NestAccfactory { if (typeof response.status !== 'number' || response.status !== 200) { throw new Error('Google API Authorisation failed with error'); } + this.special = response.data.access_token; let request = { method: 'post', @@ -487,7 +482,7 @@ export default class NestAccfactory { let restAPIURL = ''; let restAPIJSONData = {}; if (Object.keys(this.#rawData).length === 0 || (typeof fullRefresh === 'boolean' && fullRefresh === true)) { - // Setup for a full data read from Nest REST API + // Setup for a full data read from REST API restAPIURL = 'https://' + this.#connections[connectionType].restAPIHost + @@ -497,12 +492,14 @@ export default class NestAccfactory { restAPIJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] }; } if (Object.keys(this.#rawData).length !== 0 && typeof fullRefresh === 'boolean' && fullRefresh === false) { - // Setup to subscribe to object changes we know about from Nest REST API + // Setup to subscribe to object changes we know about from REST API restAPIURL = this.#connections[connectionType].transport_url + '/v6/subscribe'; restAPIJSONData = { objects: [] }; Object.entries(this.#rawData).forEach(([object_key]) => { if ( + this.#rawData[object_key]?.source === NestAccfactory.DataSource.REST && + this.#rawData[object_key]?.connection === connectionType && typeof this.#rawData[object_key]?.object_revision === 'number' && typeof this.#rawData[object_key]?.object_timestamp === 'number' ) { @@ -528,7 +525,7 @@ export default class NestAccfactory { axios(request) .then(async (response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest REST API HTTP get data failed with error'); + throw new Error('REST API subscription failed with error'); } let data = {}; @@ -602,13 +599,15 @@ export default class NestAccfactory { await axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera API HTTP get failed with error'); + throw new Error('REST API had error retrieving camera/doorbell details'); } value.value.properties = response.data.items[0].properties; }) - .catch(() => { - this?.log?.debug && this.log.debug('Error retrieving camera/doorbell additional device properties'); + .catch((error) => { + this?.log?.debug && + this?.log?.debug && + this.log.debug('REST API had error retrieving camera/doorbell details. Error was "%s"', error?.code); }); value.value.activity_zones = @@ -631,7 +630,7 @@ export default class NestAccfactory { await axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera Zones API HTTP get failed with error'); + throw new Error('REST API had error retrieving camera/doorbell activity zones'); } let zones = []; @@ -648,8 +647,10 @@ export default class NestAccfactory { value.value.activity_zones = zones; }) - .catch(() => { - this?.log?.debug && this?.log?.debug && this.log.debug('Error retrieving camera/doorbell activity zones'); + .catch((error) => { + this?.log?.debug && + this?.log?.debug && + this.log.debug('REST API had error retrieving camera/doorbell activity zones. Error was "%s"', error?.code); }); } @@ -661,7 +662,7 @@ export default class NestAccfactory { // 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 REST API structure, we need to do a full read of the data + // Since this is an added object to the raw REST API structure, we need to do a full read of the data fullRefresh = true; } }); @@ -681,17 +682,18 @@ export default class NestAccfactory { } } - // Store or update the date in our internally saved raw Nest REST API data + // 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 = connectionType; this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST; this.#rawData[value.object_key].timers = {}; // No timers running for this object this.#rawData[value.object_key].value = {}; } - // Need to check for a possible device addition to the raw Nest REST API data. + // Need to check for a possible device addition to the raw REST API data. // We expect the devices we want to add, have certain minimum properties present in the data // We'll perform that check here if ( @@ -702,7 +704,7 @@ export default class NestAccfactory { deviceChanges.push({ object_key: value.object_key, change: 'add' }); } - // Finally, update our internal raw Nest REST API data with the new values + // 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)) { @@ -711,11 +713,11 @@ export default class NestAccfactory { }), ); - await this.#processPostSubscribe(connectionType, deviceChanges); + await this.#processPostSubscribe(deviceChanges); }) .catch((error) => { if (error?.code !== 'ECONNRESET') { - this?.log?.error && this.log.error('REST API subscribe failed. Will retry'); + this?.log?.error && this.log.error('REST API subscription failed with error "%s"', error?.code); } }) .finally(() => { @@ -807,7 +809,7 @@ export default class NestAccfactory { axios(request) .then(async (response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest protobuf API HTTP get data failed with error'); + throw new Error('protobuf API had error perform trait observe'); } let deviceChanges = []; // No protobuf API devices changes to start with @@ -867,11 +869,19 @@ export default class NestAccfactory { decodedMessage.message[0].get.map(async (trait) => { if (trait.traitId.traitLabel === 'configuration_done') { if ( - (typeof this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady === 'undefined' && - trait.patch.values?.deviceReady === true) || - (typeof this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady === 'boolean' && - this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady === false && - trait.patch.values?.deviceReady === true) + this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true && + trait.patch.values?.deviceReady === true + ) { + deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); + } + } + if (trait.traitId.traitLabel === 'camera_migration_status') { + // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home + if ( + this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !== 'MIGRATED_TO_GOOGLE_HOME' && + trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' && + this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' && + trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE' ) { deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); } @@ -879,6 +889,7 @@ export default class NestAccfactory { if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') { this.#rawData[trait.traitId.resourceId] = {}; + this.#rawData[trait.traitId.resourceId].connection = connectionType; this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF; this.#rawData[trait.traitId.resourceId].timers = {}; // No timers running for this object this.#rawData[trait.traitId.resourceId].value = {}; @@ -907,7 +918,7 @@ export default class NestAccfactory { }), ); - await this.#processPostSubscribe(connectionType, deviceChanges); + await this.#processPostSubscribe(deviceChanges); deviceChanges = []; // No more device changes now } } @@ -915,7 +926,7 @@ export default class NestAccfactory { }) .catch((error) => { if (error?.code !== 'ECONNRESET') { - this?.log?.error && this.log.error('Protobuf observe error occured. Will retry'); + this?.log?.error && this.log.error('protobuf API had error perform trait observe. Error was "%s"', error?.code); } }) .finally(() => { @@ -923,7 +934,7 @@ export default class NestAccfactory { }); } - async #processPostSubscribe(connectionType, deviceChanges) { + async #processPostSubscribe(deviceChanges) { // Process any device removals we have Object.values(deviceChanges) .filter((object) => object.change === 'remove') @@ -943,11 +954,14 @@ export default class NestAccfactory { this.#eventEmitter.emit(object.object_key, HomeKitDevice.REMOVE, {}); }); - Object.values(this.#processData(connectionType, '')).forEach((deviceData) => { + Object.values(this.#processData('')).forEach((deviceData) => { // Process any device additions we have Object.values(deviceChanges) .filter((object) => object.change === 'add') .forEach((object) => { + if (object.object_key === deviceData.uuid && deviceData.excluded === true) { + this?.log?.warn && this.log.warn('Device "%s" ignored due to it being marked as excluded', deviceData.description); + } if (object.object_key === deviceData.uuid && deviceData.excluded === false) { // Device isn't marked as excluded, so create the required HomeKit accessories based upon the device data if (deviceData.device_type === NestAccfactory.DeviceType.THERMOSTAT && typeof NestThermostat === 'function') { @@ -986,7 +1000,7 @@ export default class NestAccfactory { } // Setup polling loop for camera/doorbell zone data if not already created. - // This is only required for Nest REST API data sources as these details are present in protobuf API + // This is only required for REST API data sources as these details are present in protobuf API if ( typeof this.#rawData[object.object_key]?.timers?.zones === 'undefined' && this.#rawData[object.object_key].source === NestAccfactory.DataSource.REST @@ -1000,10 +1014,11 @@ export default class NestAccfactory { '/cuepoint_category/' + object.object_key.split('.')[1], headers: { - referer: 'https://' + this.#connections[connectionType].referer, + referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, 'User-Agent': USERAGENT, - [this.#connections[connectionType].cameraAPI.key]: - this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token, + [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, }, responseType: 'json', timeout: CAMERAZONEPOLLING, @@ -1011,7 +1026,7 @@ export default class NestAccfactory { await axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera Zones API HTTP get failed with error'); + throw new Error('REST API had error retrieving camera/doorbell activity zones'); } let zones = []; @@ -1033,8 +1048,16 @@ export default class NestAccfactory { activity_zones: this.#rawData[object.object_key].value.activity_zones, }); }) - .catch(() => { - this?.log?.debug && this.log.debug('Error retrieving camera/doorbell activity zones'); + .catch((error) => { + // Log debug message if wasn't a timeout + if (error?.code !== 'ECONNABORTED') { + this?.log?.debug && + this.log.debug( + 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"', + object.object_key, + error?.code, + ); + } }); } }, CAMERAZONEPOLLING); @@ -1047,100 +1070,62 @@ export default class NestAccfactory { typeof this.#rawData[object.object_key]?.value === 'object' && this.#rawData[object.object_key]?.source === NestAccfactory.DataSource.PROTOBUF ) { - let protobufElement = { - resourceRequest: { - resourceId: object.object_key, - 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, - }, + let alerts = []; // No alerts yet + + let commandResponse = await this.#protobufCommand(object.object_key, [ + { + 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, }, }, }, - ], - }; - - let alerts = []; // No alerts yet - let trait = this.#connections[connectionType].protobufRoot.lookup( - 'nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest', - ); - protobufElement.resourceCommands[0].command.value = trait - .encode(trait.fromObject(protobufElement.resourceCommands[0].command.value)) - .finish(); - let TraitMap = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v1.ResourceCommandRequest'); - let encodedData = TraitMap.encode(TraitMap.fromObject(protobufElement)).finish(); - - let request = { - method: 'post', - url: 'https://' + this.#connections[connectionType].protobufAPIHost + '/nestlabs.gateway.v1.ResourceApi/SendCommand', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[connectionType].token, - 'Content-Type': 'application/x-protobuf', - 'X-Accept-Content-Transfer-Encoding': 'binary', - 'X-Accept-Response-Streaming': 'true', }, - responseType: 'arraybuffer', - data: encodedData, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest protobuf API HTTP get data failed with error'); - } - - let decodedData = this.#connections[connectionType].protobufRoot - .lookupType('nestlabs.gateway.v1.ResourceCommandResponseFromAPI') - .decode(response.data) - .toJSON(); - if ( - typeof decodedData?.resourceCommandResponse[0]?.traitOperations[0]?.event?.event?.cameraEventWindow - ?.cameraEvent === 'object' - ) { - decodedData.resourceCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach( - (event) => { - alerts.push({ - 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) => - typeof zone?.zoneIndex === 'number' ? zone.zoneIndex : zone.internalIndex, - ) - : [], - types: event.eventType - .map((event) => (event.startsWith('EVENT_') === true ? event.split('EVENT_')[1].toLowerCase() : '')) - .filter((event) => event), - }); - - // Fix up even types to match REST API - // <---- TODO (as the ones we use match from protobuf) - }, - ); + ]); - // Sort alerts to be most recent first - alerts = alerts.sort((a, b) => { - if (a.start_time > b.start_time) { - return -1; - } + if ( + typeof commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow + ?.cameraEvent === 'object' + ) { + commandResponse.resourceCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach( + (event) => { + alerts.push({ + 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) => + typeof zone?.zoneIndex === 'number' ? zone.zoneIndex : zone.internalIndex, + ) + : [], + types: event.eventType + .map((event) => (event.startsWith('EVENT_') === true ? event.split('EVENT_')[1].toLowerCase() : '')) + .filter((event) => event), }); + + // Fix up event types to match REST API + // 'EVENT_UNFAMILIAR_FACE' = 'unfamiliar-face' + // 'EVENT_PERSON_TALKING' = 'personHeard' + // 'EVENT_DOG_BARKING' = 'dogBarking' + // <---- TODO (as the ones we use match from protobuf) + }, + ); + + // Sort alerts to be most recent first + alerts = alerts.sort((a, b) => { + if (a.start_time > b.start_time) { + return -1; } - }) - .catch(() => { - this?.log?.debug && this.log.debug('Error retrieving camera/doorbell activity notifications'); }); + } this.#rawData[object.object_key].value.alerts = alerts; @@ -1164,10 +1149,11 @@ export default class NestAccfactory { '/2?start_time=' + Math.floor(Date.now() / 1000 - 30), headers: { - referer: 'https://' + this.#connections[connectionType].referer, + referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, 'User-Agent': USERAGENT, - [this.#connections[connectionType].cameraAPI.key]: - this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token, + [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, }, responseType: 'json', timeout: CAMERAALERTPOLLING, @@ -1175,7 +1161,7 @@ export default class NestAccfactory { await axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Camera Alert API HTTP get failed with error'); + throw new Error('REST API had error retrieving camera/doorbell activity notifications'); } response.data.forEach((alert) => { @@ -1202,8 +1188,16 @@ export default class NestAccfactory { } }); }) - .catch(() => { - this?.log?.debug && this.log.debug('Error retrieving camera/doorbell activity notifications'); + .catch((error) => { + // Log debug message if wasn't a timeout + if (error?.code !== 'ECONNABORTED') { + this?.log?.debug && + this.log.debug( + 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"', + object.object_key, + error?.code, + ); + } }); this.#rawData[object.object_key].value.alerts = alerts; @@ -1216,7 +1210,6 @@ export default class NestAccfactory { }, CAMERAALERTPOLLING); } } - if (deviceData.device_type === NestAccfactory.DeviceType.WEATHER && typeof NestWeather === 'function') { // Nest 'Virtual' weather station - Categories.SENSOR = 10 let tempDevice = new NestWeather(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); @@ -1226,7 +1219,7 @@ export default class NestAccfactory { if (typeof this.#rawData[object.object_key]?.timers?.weather === 'undefined') { this.#rawData[object.object_key].timers.weather = setInterval(async () => { this.#rawData[object.object_key].value.weather = await this.#getWeatherData( - connectionType, + this.#rawData[object.object_key].connection, object.object_key, this.#rawData[object.object_key].value.weather.latitude, this.#rawData[object.object_key].value.weather.longitude, @@ -1236,7 +1229,7 @@ export default class NestAccfactory { this.#eventEmitter.emit( object.object_key, HomeKitDevice.UPDATE, - this.#processData(connectionType, object.object_key)[deviceData.serial_number], + this.#processData(object.object_key)[deviceData.serial_number], ); }, WEATHERPOLLING); } @@ -1251,7 +1244,7 @@ export default class NestAccfactory { }); } - #processData(connectionType, deviceUUID) { + #processData(deviceUUID) { if (typeof deviceUUID !== 'string') { deviceUUID = ''; } @@ -1337,8 +1330,8 @@ export default class NestAccfactory { .filter((s) => s) .join(':') .toUpperCase(); - delete data.mac_address; } + delete data.mac_address; processed = data; // eslint-disable-next-line no-unused-vars @@ -1372,16 +1365,16 @@ export default class NestAccfactory { RESTTypeData.software_version = value.value.device_identity.softwareVersion; RESTTypeData.model = 'Thermostat'; if (value.value.device_info.typeName === 'nest.resource.NestLearningThermostat3Resource') { - RESTTypeData.model = 'Learning Thermostat (3rd Gen)'; + RESTTypeData.model = 'Learning Thermostat (3rd gen)'; } if (value.value.device_info.typeName === 'google.resource.GoogleBismuth1Resource') { - RESTTypeData.model = 'Learning Thermostat (4th Gen)'; + RESTTypeData.model = 'Learning Thermostat (4th gen)'; } if (value.value.device_info.typeName === 'nest.resource.NestAgateDisplayResource') { RESTTypeData.model = 'Thermostat E'; } if (value.value.device_info.typeName === 'nest.resource.NestOnyxResource') { - RESTTypeData.model = 'Thermostat E (1st Gen)'; + RESTTypeData.model = 'Thermostat E (1st gen)'; } if (value.value.device_info.typeName === 'google.resource.GoogleZirconium1Resource') { RESTTypeData.model = 'Thermostat (2020 Model)'; @@ -1535,7 +1528,7 @@ export default class NestAccfactory { ? value.value.remote_comfort_sensing_settings.activeRcsSelection.activeRcsSensor.resourceId : ''; RESTTypeData.linked_rcs_sensors = []; - if (typeof value.value.remote_comfort_sensing_settings.associatedRcsSensors === 'object') { + if (typeof value.value?.remote_comfort_sensing_settings?.associatedRcsSensors === 'object') { value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => { if (typeof this.#rawData?.[sensor.deviceId.resourceId]?.value === 'object') { this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat @@ -1544,14 +1537,16 @@ export default class NestAccfactory { // 'liveness' property doesn't appear in protobuf data for temp sensors, so we'll add that object here this.#rawData[sensor.deviceId.resourceId].value.liveness = {}; this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_UNSPECIFIED'; - Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => { - if ( - sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId && - sensorStatus?.dataRecency?.includes('OK') === true - ) { - this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_ONLINE'; - } - }); + if (typeof value.value?.remote_comfort_sensing_state?.rcsSensorStatuses === 'object') { + Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => { + if ( + sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId && + sensorStatus?.dataRecency?.includes('OK') === true + ) { + this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_ONLINE'; + } + }); + } } RESTTypeData.linked_rcs_sensors.push(sensor.deviceId.resourceId); @@ -1598,16 +1593,16 @@ export default class NestAccfactory { RESTTypeData.software_version = value.value.current_version; RESTTypeData.model = 'Thermostat'; if (value.value.serial_number.serial_number.substring(0, 2) === '15') { - RESTTypeData.model = 'Thermostat E (1st Gen)'; // Nest Thermostat E + RESTTypeData.model = 'Thermostat E (1st gen)'; // Nest Thermostat E } if (value.value.serial_number.serial_number.substring(0, 2) === '09') { - RESTTypeData.model = 'Thermostat (3rd Gen)'; // Nest Thermostat 3rd Gen + RESTTypeData.model = 'Thermostat (3rd gen)'; // Nest Thermostat 3rd Gen } if (value.value.serial_number.serial_number.substring(0, 2) === '02') { - RESTTypeData.model = 'Thermostat (2nd Gen)'; // Nest Thermostat 2nd Gen + RESTTypeData.model = 'Thermostat (2nd gen)'; // Nest Thermostat 2nd Gen } if (value.value.serial_number.serial_number.substring(0, 2) === '01') { - RESTTypeData.model = 'Thermostat (1st Gen)'; // Nest Thermostat 1st Gen + RESTTypeData.model = 'Thermostat (1st gen)'; // Nest Thermostat 1st Gen } RESTTypeData.current_humidity = value.value.current_humidity; RESTTypeData.temperature_scale = value.value.temperature_scale; @@ -1856,6 +1851,7 @@ export default class NestAccfactory { .join(':') .toUpperCase(); // Create mac_address in format of xx:xx:xx:xx:xx:xx } + delete data.mac_address; processed = data; // eslint-disable-next-line no-unused-vars @@ -1950,10 +1946,10 @@ export default class NestAccfactory { data.model = data.model + ' (battery'; // Battery powered } if (data.serial_number.substring(0, 2) === '06') { - data.model = data.model + ', 2nd Gen)'; // Nest Protect 2nd Gen + data.model = data.model + ', 2nd gen)'; // Nest Protect 2nd Gen } if (data.serial_number.substring(0, 2) === '05') { - data.model = data.model + ', 1st Gen)'; // Nest Protect 1st Gen + data.model = data.model + ', 1st gen)'; // Nest Protect 1st Gen } let description = typeof data?.description === 'string' ? data.description : ''; let location = typeof data?.location === 'string' ? data.location : ''; @@ -1982,8 +1978,8 @@ export default class NestAccfactory { .filter((s) => s) .join(':') .toUpperCase(); - delete data.mac_address; } + delete data.mac_address; processed = data; // eslint-disable-next-line no-unused-vars @@ -2136,13 +2132,8 @@ export default class NestAccfactory { .filter((s) => s) .join(':') .toUpperCase(); - delete data.mac_address; - } - - // Insert details to allow access to camera API calls for the device - if (typeof this.#connections?.[connectionType]?.cameraAPI === 'object') { - data.apiAccess = this.#connections[connectionType].cameraAPI; } + delete data.mac_address; processed = data; // eslint-disable-next-line no-unused-vars @@ -2175,12 +2166,13 @@ export default class NestAccfactory { .forEach(([object_key, value]) => { let tempDevice = {}; try { - if (value.source === NestAccfactory.DataSource.PROTOBUF) { - /* + if (value.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) { let RESTTypeData = {}; - RESTTypeData.mac_address = value.value.wifi_interface.macAddress.toString('hex'); + //RESTTypeData.mac_address = value.value.wifi_interface.macAddress.toString('hex'); + // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits. + RESTTypeData.mac_address = '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase()).toUpperCase(); RESTTypeData.serial_number = value.value.device_identity.serialNumber; - RESTTypeData.software_version = value.value.device_identity.softwareVersion; + RESTTypeData.software_version = value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, ''); RESTTypeData.model = 'Camera'; if (value.value.device_info.typeName === 'google.resource.NeonQuartzResource') { RESTTypeData.model = 'Cam (battery)'; @@ -2192,19 +2184,19 @@ export default class NestAccfactory { RESTTypeData.model = 'Cam (wired)'; } if (value.value.device_info.typeName === 'google.resource.VenusResource') { - RESTTypeData.model = 'Doorbell (wired, 2nd Gen)'; + RESTTypeData.model = 'Doorbell (wired, 2nd gen)'; } if (value.value.device_info.typeName === 'nest.resource.NestCamIndoorResource') { - RESTTypeData.model = 'Cam Indoor (1st Gen)'; + RESTTypeData.model = 'Cam Indoor (1st gen)'; } if (value.value.device_info.typeName === 'nest.resource.NestCamIQResource') { RESTTypeData.model = 'Cam IQ'; } if (value.value.device_info.typeName === 'nest.resource.NestCamIQOutdoorResource') { - RESTTypeData.model = 'Cam Outdoor (1st Gen)'; + RESTTypeData.model = 'Cam Outdoor (1st gen)'; } if (value.value.device_info.typeName === 'nest.resource.NestHelloResource') { - RESTTypeData.model = 'Doorbell (wired, 1st Gen)'; + RESTTypeData.model = 'Doorbell (wired, 1st gen)'; } if (value.value.device_info.typeName === 'google.resource.AzizResource') { RESTTypeData.model = 'Cam with Floodlight (wired)'; @@ -2224,8 +2216,6 @@ export default class NestAccfactory { value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_ELECTRONIC'; RESTTypeData.indoor_chime_enabled = value.value?.doorbell_indoor_chime_settings?.chimeEnabled === true; RESTTypeData.streaming_enabled = value.value?.recording_toggle?.currentCameraState === 'CAMERA_ON'; - RESTTypeData.direct_nexustalk_host = - typeof value.value?.streaming_protocol?.directHost?.value === 'string' ? value.value.streaming_protocol.directHost.value : ''; //RESTTypeData.has_irled = //RESTTypeData.irled_enabled = //RESTTypeData.has_statusled = @@ -2249,15 +2239,15 @@ export default class NestAccfactory { parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.seconds) !== 0 && Math.floor(Date.now() / 1000) < parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.second); RESTTypeData.camera_type = value.value.device_identity.vendorProductId; - RESTTypeData.migration_in_progress = - value.value?.camera_migration_status?.state?.progress !== 'PROGRESS_NONE' && - value.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE'; + RESTTypeData.streaming_protocols = + value.value?.streaming_protocol?.supportedProtocols !== undefined ? value.value.streaming_protocol.supportedProtocols : []; + RESTTypeData.streaming_host = + typeof value.value?.streaming_protocol?.directHost?.value === 'string' ? value.value.streaming_protocol.directHost.value : ''; tempDevice = process_camera_doorbell_data(object_key, RESTTypeData); - */ } - if (value.source === NestAccfactory.DataSource.REST) { + if (value.source === NestAccfactory.DataSource.REST && value.value.properties['cc2migration.overview_state'] === 'NORMAL') { // We'll only use the REST API data for Camera's which have NOT been migrated to Google Home let RESTTypeData = {}; RESTTypeData.mac_address = value.value.mac_address; @@ -2267,7 +2257,6 @@ export default class NestAccfactory { RESTTypeData.description = value.value?.description; RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id); RESTTypeData.streaming_enabled = value.value.streaming_state.includes('enabled') === true; - RESTTypeData.direct_nexustalk_host = value.value.direct_nexustalk_host; RESTTypeData.nexus_api_http_server_url = value.value.nexus_api_http_server_url; RESTTypeData.online = value.value.streaming_state.includes('offline') === false; RESTTypeData.audio_enabled = value.value.audio_input_enabled === true; @@ -2284,15 +2273,12 @@ export default class NestAccfactory { RESTTypeData.has_motion_detection = value.value.capabilities.includes('detectors.on_camera') === true; RESTTypeData.activity_zones = value.value.activity_zones; // structure elements we added RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : []; - RESTTypeData.streaming_protocols = ['NEXUSTALK']; + RESTTypeData.streaming_protocols = ['PROTOCOL_NEXUSTALK']; + RESTTypeData.streaming_host = value.value.direct_nexustalk_host; RESTTypeData.quiet_time_enabled = false; RESTTypeData.camera_type = value.value.camera_type; - RESTTypeData.migration_in_progress = - value.value.properties['cc2migration.overview_state'] === 'FORWARD_MIGRATION_IN_PROGRESS' || - value.value.properties['cc2migration.overview_state'] === 'REVERSE_MIGRATION_IN_PROGRESS'; + tempDevice = process_camera_doorbell_data(object_key, RESTTypeData); - // If the camera/doorbell is being/or has been migrated to Google Home, we'll explicitly exclude this device from REST API data - tempDevice.excluded = value.value.properties['cc2migration.overview_state'] !== 'NORMAL' ? true : tempDevice.excluded; } // eslint-disable-next-line no-unused-vars } catch (error) { @@ -2300,6 +2286,11 @@ export default class NestAccfactory { } if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') { + // Insert details to allow access to camera API calls for the device + if (value.connection !== undefined && typeof this.#connections?.[value.connection]?.cameraAPI === 'object') { + tempDevice.apiAccess = this.#connections[value.connection].cameraAPI; + } + // Insert any extra options we've read in from configuration file for this device tempDevice.eveHistory = this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true; @@ -2316,15 +2307,16 @@ export default class NestAccfactory { typeof this.config?.devices?.[tempDevice.serial_number]?.personCooldown === 'number' ? this.config.devices[tempDevice.serial_number].personCooldown : 120; - tempDevice.chimeSwitch = this.config?.devices?.[tempDevice.serial_number]?.chimeSwitch === true; // Config option for chime switch + tempDevice.chimeSwitch = this.config?.devices?.[tempDevice.serial_number]?.chimeSwitch === true; // Control 'indoor' chime by switch + tempDevice.localAccess = this.config?.devices?.[tempDevice.serial_number]?.localAccess === true; // Local network video streaming rather than from cloud from camera/doorbells tempDevice.ffmpeg = this.config.options.ffmpeg; // ffmpeg details, path, libraries. No ffmpeg = undefined - (tempDevice.maxStreams = - typeof this.config.options?.maxStreams === 'number' ? this.config.options.maxStreams : this.deviceData.hksv === true ? 1 : 2), - (devices[tempDevice.serial_number] = tempDevice); // Store processed device + tempDevice.maxStreams = + typeof this.config.options?.maxStreams === 'number' ? this.config.options.maxStreams : this.deviceData.hksv === true ? 1 : 2; + devices[tempDevice.serial_number] = tempDevice; // Store processed device } }); - // Process data for any structure(s) for both Nest REST and protobuf API data + // Process data for any structure(s) for both REST and protobuf API data // We use this to created virtual weather station(s) for each structure that has location data const process_structure_data = (object_key, data) => { let processed = {}; @@ -2423,8 +2415,8 @@ export default class NestAccfactory { let RESTTypeData = {}; RESTTypeData.postal_code = value.value.postal_code; RESTTypeData.country_code = value.value.country_code; - RESTTypeData.city = value.value.city; - RESTTypeData.state = value.value.state; + RESTTypeData.city = typeof value.value?.city === 'string' ? value.value.city : ''; + RESTTypeData.state = typeof value.value?.state === 'string' ? value.value.state : ''; RESTTypeData.latitude = value.value.latitude; RESTTypeData.longitude = value.value.longitude; RESTTypeData.description = @@ -2448,16 +2440,21 @@ export default class NestAccfactory { return devices; // Return our processed data } - async #set(connectionType, deviceUUID, values) { - if (typeof deviceUUID !== 'string' && typeof this.#rawData[deviceUUID] !== 'object' && typeof values !== 'object') { + async #set(deviceUUID, values) { + if ( + typeof deviceUUID !== 'string' || + typeof this.#rawData[deviceUUID] !== 'object' || + typeof values !== 'object' || + typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + ) { return; } if ( - this.#connections[connectionType].protobufRoot !== null && + this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null && this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF ) { - let TraitMap = this.#connections[connectionType].protobufRoot.lookup('nest.rpc.NestTraitSetRequest'); + let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup('nest.rpc.NestTraitSetRequest'); let setDataToEncode = []; let protobufElement = { traitId: { @@ -2656,11 +2653,13 @@ export default class NestAccfactory { } if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') { - this.platorm.log.debug('Unknown protobuf set key for device', deviceUUID, key, value); + this?.log?.debug && this.log.debug('Unknown protobuf set key for device', deviceUUID, key, value); } if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') { - let trait = this.#connections[connectionType].protobufRoot.lookup(protobufElement.property.type_url.split('/')[1]); + let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup( + protobufElement.property.type_url.split('/')[1], + ); protobufElement.property.value = trait.encode(trait.fromObject(protobufElement.property.value)).finish(); // eslint-disable-next-line no-undef setDataToEncode.push(structuredClone(protobufElement)); @@ -2668,14 +2667,17 @@ export default class NestAccfactory { }), ); - if (setDataToEncode.length !== 0) { + if (setDataToEncode.length !== 0 && TraitMap !== null) { let encodedData = TraitMap.encode(TraitMap.fromObject({ set: setDataToEncode })).finish(); let request = { method: 'post', - url: 'https://' + this.#connections[connectionType].protobufAPIHost + '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState', + url: + 'https://' + + this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + + '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState', headers: { 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[connectionType].token, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, 'Content-Type': 'application/x-protobuf', 'X-Accept-Content-Transfer-Encoding': 'binary', 'X-Accept-Response-Streaming': 'true', @@ -2685,11 +2687,12 @@ export default class NestAccfactory { axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Protobuf API trait update failed'); + throw new Error('protobuf API had error updating device traits'); } }) - .catch(() => { - this?.log?.debug && this.log.debug('Protobuf API trait update for failed for uuid "%s"', deviceUUID); + .catch((error) => { + this?.log?.debug && + this.log.debug('protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code); }); } } @@ -2700,13 +2703,14 @@ export default class NestAccfactory { Object.entries(values).map(async ([key, value]) => { let request = { method: 'post', - url: 'https://webapi.' + this.#connections[connectionType].cameraAPIHost + '/api/dropcams.set_properties', + url: 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties', headers: { - referer: 'https://' + this.#connections[connectionType].referer, + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, 'User-Agent': USERAGENT, 'content-type': 'application/x-www-form-urlencoded', - [this.#connections[connectionType].cameraAPI.key]: - this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token, + [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, }, responseType: 'json', timeout: NESTAPITIMEOUT, @@ -2720,11 +2724,12 @@ export default class NestAccfactory { typeof response.data.status !== 'number' || response.data.status !== 0 ) { - throw new Error('REST Camera API update for failed'); + throw new Error('REST API camera update for failed with error'); } }) - .catch(() => { - this?.log?.debug && this.log.debug('REST Camera API update for failed for uuid "%s"', deviceUUID); + .catch((error) => { + this?.log?.debug && + this.log.debug('REST API camera update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); }); }), ); @@ -2764,22 +2769,23 @@ export default class NestAccfactory { if (restAPIJSONData.objects.length !== 0) { let request = { method: 'post', - url: this.#connections[connectionType].transport_url + '/v5/put', + url: this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put', responseType: 'json', headers: { 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[connectionType].token, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, }, data: JSON.stringify(restAPIJSONData), }; await axios(request) .then(async (response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API update for failed'); + throw new Error('REST API property update for failed with error'); } }) - .catch(() => { - this?.log?.debug && this.log.debug('REST API update for failed for uuid "%s"', deviceUUID); + .catch((error) => { + this?.log?.debug && + this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); }); } }), @@ -2787,9 +2793,119 @@ export default class NestAccfactory { } } - async #get(connectionType, deviceUUID, values) { - // <--- Yet to implement - this?.log?.debug && this.log.debug('function get was called with', connectionType, deviceUUID, values); + async #get(deviceUUID, values) { + if ( + typeof deviceUUID !== 'string' || + typeof this.#rawData[deviceUUID] !== 'object' || + typeof values !== 'object' || + typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + ) { + values = {}; + } + + await Promise.all( + Object.entries(values).map(async ([key]) => { + // We'll return the data under the original key value + // By default, the returned value will be undefined. If call is successful, the key value will have the data requested + values[key] = undefined; + + if ( + this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && + key === 'camera_snapshot' && + deviceUUID.startsWith('quartz.') === true + ) { + // Attempt to retrieve snapshot from camera via REST API + let request = { + method: 'get', + url: this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1], + headers: { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + 'User-Agent': USERAGENT, + accept: '*/*', + [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + }, + responseType: 'arraybuffer', + timeout: 3000, + }; + + // if (typeof keyValue keyValue !== '') + /* (url = + this.#rawData[deviceUUID].value.nexus_api_http_server_url + + '/event_snapshot/' + + deviceUUID.split('.')[1] + + '/' + + id + + '?crop_type=timeline&cachebuster=' + + Math.floor(Date.now() / 1000)), */ + + await axios(request) + .then((response) => { + if (typeof response.status !== 'number' || response.status !== 200) { + throw new Error('REST API camera snapshot failed with error'); + } + + values[key] = response.data; + }) + .catch((error) => { + this?.log?.debug && + this.log.debug('REST API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + }); + } + + if ( + this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF && + this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null && + this.#rawData[deviceUUID]?.value?.device_identity?.vendorProductId !== undefined && + key === 'camera_snapshot' + ) { + // Attempt to retrieve snapshot from camera via protobuf API + // First, request to get snapshot url image updated + let commandResponse = await this.#protobufCommand(deviceUUID, [ + { + traitLabel: 'upload_live_image', + command: { + type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest', + value: {}, + }, + }, + ]); + + if (commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE') { + // Snapshot url image has beeen updated, so no retrieve it + let request = { + method: 'get', + url: this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl, + headers: { + 'User-Agent': USERAGENT, + accept: '*/*', + [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + }, + responseType: 'arraybuffer', + timeout: 3000, + }; + await axios(request) + .then((response) => { + if (typeof response.status !== 'number' || response.status !== 200) { + throw new Error('protobuf API camera snapshot failed with error'); + } + + values[key] = response.data; + }) + .catch((error) => { + this?.log?.debug && + this.log.debug('protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + }); + } + } + }), + ); + + // Send results back via event + this.#eventEmitter.emit(HomeKitDevice.GET + '->' + deviceUUID, values); } async #getWeatherData(connectionType, deviceUUID, latitude, longitude) { @@ -2810,7 +2926,7 @@ export default class NestAccfactory { await axios(request) .then((response) => { if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST Weather API retrieving details failed'); + throw new Error('REST API failed to retireve weather details'); } if (typeof response.data[latitude + ',' + longitude].current === 'object') { @@ -2830,11 +2946,88 @@ export default class NestAccfactory { weatherData.forecast = response.data[latitude + ',' + longitude].forecast.daily[0].condition; } }) - .catch(() => { - this?.log?.debug && this.log.debug('REST Weather API retrieving details failed'); + .catch((error) => { + this?.log?.debug && + this.log.debug('REST API failed to retireve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code); }); return weatherData; } + + async #protobufCommand(deviceUUID, commands) { + if ( + typeof deviceUUID !== 'string' || + typeof this.#rawData?.[deviceUUID] !== 'object' || + this.#rawData[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF || + Array.isArray(commands === false) || + typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + ) { + return; + } + + let commandResponse = undefined; + let encodedData = undefined; + + // Build the protobuf command object for encoding + let protobufElement = { + resourceRequest: { + resourceId: deviceUUID, + requestId: crypto.randomUUID(), + }, + resourceCommands: commands, + }; + + // End code each of the commands + protobufElement.resourceCommands.forEach((command) => { + let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(command.command.type_url.split('/')[1]); + if (trait !== null) { + command.command.value = trait.encode(trait.fromObject(command.command.value)).finish(); + } + }); + + let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup( + 'nestlabs.gateway.v1.ResourceCommandRequest', + ); + if (TraitMap !== null) { + encodedData = TraitMap.encode(TraitMap.fromObject(protobufElement)).finish(); + } + + if (encodedData !== undefined) { + let request = { + method: 'post', + url: + 'https://' + + this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + + '/nestlabs.gateway.v1.ResourceApi/SendCommand', + headers: { + 'User-Agent': USERAGENT, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + 'Content-Type': 'application/x-protobuf', + 'X-Accept-Content-Transfer-Encoding': 'binary', + 'X-Accept-Response-Streaming': 'true', + }, + responseType: 'arraybuffer', + data: encodedData, + }; + + await axios(request) + .then((response) => { + if (typeof response.status !== 'number' || response.status !== 200) { + throw new Error('protobuf command send failed with error'); + } + + commandResponse = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot + .lookup('nestlabs.gateway.v1.ResourceCommandResponseFromAPI') + .decode(response.data) + .toJSON(); + }) + .catch((error) => { + this?.log?.debug && + this.log.debug('protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + }); + } + + return commandResponse; + } } // General helper functions which don't need to be part of an object class diff --git a/src/webrtc.js b/src/webrtc.js new file mode 100644 index 0000000..aee28d5 --- /dev/null +++ b/src/webrtc.js @@ -0,0 +1,55 @@ +// WebRTC +// Part of homebridge-nest-accfactory +// +// Handles connection and data from Google WeBRTC systems +// +// Code version 6/9/2024 +// Mark Hulskamp +'use strict'; + +// Define external library requirements +//import axios from 'axios'; +//import protobuf from 'protobufjs'; + +// Define nodejs module requirements +//import { Buffer } from 'node:buffer'; +//import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers'; +//import tls from 'tls'; +//import crypto from 'crypto'; + +// Define our modules +import Streamer from './streamer.js'; + +// Define constants + +// WebRTC object +export default class WebRTC extends Streamer { + constructor(deviceData, options) { + super(deviceData, options); + + this.host = deviceData?.streaming_host; // Host we'll connect to + + // If specified option to start buffering, kick off + if (typeof options?.buffer === 'boolean' && options.buffer === true) { + this.startBuffering(); + } + } + + // Class functions + connect(host) { + this.log.info(host); + } + + close(stopStreamFirst) { + this.log.info(stopStreamFirst); + } + + update(deviceData) { + // Let our parent handle the remaining updates + super.update(deviceData); + } + + talkingAudio(talkingData) { + this.log.info(talkingData); + } +} From 9a6707f1d9cf2eb58061bb3efb933115e0bce598 Mon Sep 17 00:00:00 2001 From: Yn0rt0nthec4t Date: Wed, 11 Sep 2024 14:04:26 +1000 Subject: [PATCH 2/5] 0.1.7-alpha.1 source files --- README.md | 10 +- package.json | 11 +- src/HomeKitDevice.js | 14 +- src/HomeKitHistory.js | 26 +- src/camera.js | 436 +++---- src/doorbell.js | 6 +- src/floodlight.js | 97 ++ src/index.js | 14 +- src/nexustalk.js | 637 ++++------ src/protect.js | 10 +- src/protobuf/googlehome/foyer.proto | 17 +- src/protobuf/nest/messages.proto | 22 + src/protobuf/nest/nexustalk.proto | 181 +++ src/protobuf/root.proto | 29 +- src/streamer.js | 63 +- src/system.js | 1834 +++++++++++++-------------- src/thermostat.js | 8 +- 17 files changed, 1724 insertions(+), 1691 deletions(-) create mode 100644 src/floodlight.js create mode 100644 src/protobuf/nest/nexustalk.proto diff --git a/README.md b/README.md index 34f3ab5..ac28333 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ This is a HAP-NodeJS accessory I have developed to allow Nest devices to be used The following Nest devices are supported -* Nest Thermostats (Gen 1, Gen 2, Gen 3, E) -* Nest Protects (Gen 1, Gen 2) -* Nest Temp Sensors -* Nest Cameras (Cam Indoor, IQ Indoor, Outdoor, IQ Outdoor) -* Nest Hello (Wired Gen 1) +* Nest Thermostats (1st gen, 2nd gen, 3rd gen, E, 2020 mirror edition, 4th gen) +* Nest Protects (1st and 2nd gen) +* Nest Temp Sensors (1st gen) +* Nest Cameras (Cam Indoor, IQ Indoor, Outdoor, IQ Outdoor, Cam with Floodlight) +* Nest Doorbells (wired 1st gen) The accessory supports connection to Nest using a Nest account OR a Google (migrated Nest account) account. diff --git a/package.json b/package.json index 112ae6e..5dfa9db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "displayName": "Nest Accfactory", "name": "nest-accfactory", "homepage": "https://github.com/n0rt0nthec4t/Nest_accfactory", - "version": "0.1.6", + "version": "0.1.7-alpha.1", "description": "HomeKit integration for Nest devices using HAP-NodeJS library", "license": "Apache-2.0", "author": "n0rt0nthec4t", @@ -42,13 +42,14 @@ "thermostat", "temperature", "smoke", - "sensor" + "sensor", + "floodlight" ], "devDependencies": { "@eslint/js": "^9.10.0", - "@stylistic/eslint-plugin": "^2.7.2", + "@stylistic/eslint-plugin": "^2.8.0", "@types/node": "^20.16.1", - "@typescript-eslint/parser": "^8.4.0", + "@typescript-eslint/parser": "^8.5.0", "copyfiles": "^2.4.1", "eslint": "^9.10.0", "nodemon": "^3.1.4", @@ -58,9 +59,7 @@ }, "dependencies": { "hap-nodejs": "^1.1.0", - "axios": "^1.7.7", "chalk": "^5.3.0", - "pbf": "^4.0.1", "protobufjs": "^7.4.0", "ws": "^8.18.0" } diff --git a/src/HomeKitDevice.js b/src/HomeKitDevice.js index 9e91f03..ef0048c 100644 --- a/src/HomeKitDevice.js +++ b/src/HomeKitDevice.js @@ -37,7 +37,7 @@ // HomeKitDevice.updateServices(deviceData) // HomeKitDevice.messageServices(type, message) // -// Code version 6/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -239,7 +239,7 @@ export default class HomeKitDevice { } } - async remove() { + remove() { this?.log?.warn && this.log.warn('Device "%s" has been removed', this.deviceData.description); if (this.#eventEmitter === undefined && typeof this.deviceData?.uuid === 'string' && this.deviceData.uuid !== '') { @@ -249,7 +249,7 @@ export default class HomeKitDevice { if (typeof this.removeServices === 'function') { try { - await this.removeServices(); + this.removeServices(); } catch (error) { this?.log?.error && this.log.error('removeServices call for device "%s" failed. Error was', this.deviceData.description, error); } @@ -278,7 +278,7 @@ export default class HomeKitDevice { // delete this; } - async update(deviceData, forceUpdate) { + update(deviceData, forceUpdate) { if (typeof deviceData !== 'object' || typeof forceUpdate !== 'boolean') { return; } @@ -357,7 +357,7 @@ export default class HomeKitDevice { if (typeof this.updateServices === 'function') { try { - await this.updateServices(deviceData); // Pass updated data on for accessory to process as it needs + this.updateServices(deviceData); // Pass updated data on for accessory to process as it needs } catch (error) { this?.log?.error && this.log.error('updateServices call for device "%s" failed. Error was', this.deviceData.description, error); } @@ -402,7 +402,7 @@ export default class HomeKitDevice { return results?.[0]; } - async #message(type, message) { + #message(type, message) { switch (type) { case HomeKitDevice.ADD: { // Got message for device add @@ -428,7 +428,7 @@ export default class HomeKitDevice { // This is not a message we know about, so pass onto accessory for it to perform any processing if (typeof this.messageServices === 'function') { try { - await this.messageServices(type, message); + this.messageServices(type, message); } catch (error) { this?.log?.error && this.log.error('messageServices call for device "%s" failed. Error was', this.deviceData.description, error); diff --git a/src/HomeKitHistory.js b/src/HomeKitHistory.js index 9b277f5..165c89c 100644 --- a/src/HomeKitHistory.js +++ b/src/HomeKitHistory.js @@ -8,6 +8,8 @@ // -- Eve Degree/Weather2 history // -- Eve Water guard history // +// Credit to https://github.com/simont77/fakegato-history for the work on starting the EveHome comms protocol decoding +// // Version 29/8/2024 // Mark Hulskamp @@ -2073,15 +2075,6 @@ export default class HomeKitHistory { numberToEveHexString(1, 8), ); // first entry - if (this?.log?.debug) { - this.log.debug( - '#EveHistoryStatus: history for "%s:%s" (%s) - Entries %s', - this.EveHome.type, - this.EveHome.sub, - this.EveHome.evetype, - this.EveHome.count, - ); - } return encodeEveData(value); } @@ -2299,26 +2292,11 @@ export default class HomeKitHistory { } if (this.EveHome.entry > this.EveHome.count) { // No more history data to send back - this?.log?.debug && - this.log.debug( - '#EveHistoryEntries: sent "%s" entries to EveHome ("%s") for "%s:%s"', - this.EveHome.send, - this.EveHome.evetype, - this.EveHome.type, - this.EveHome.sub, - ); this.EveHome.send = 0; // no more to send dataStream += '00'; } } else { // We're not transferring any data back - this?.log?.debug && - this.log.debug( - '#EveHistoryEntries: no more entries to send to EveHome ("%s") for "%s:%s', - this.EveHome.evetype, - this.EveHome.type, - this.EveHome.sub, - ); this.EveHome.send = 0; // no more to send dataStream = '00'; } diff --git a/src/camera.js b/src/camera.js index 03818e3..001aebd 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,7 +1,7 @@ // Nest Cameras // Part of homebridge-nest-accfactory // -// Code version 7/9/2024 +// Code version 8/9/2024 // Mark Hulskamp 'use strict'; @@ -78,7 +78,128 @@ export default class NestCamera extends HomeKitDevice { } // Setup additional services/characteristics after we have a controller created - this.createCameraServices(); + this.operatingModeService = this.controller?.recordingManagement?.operatingModeService; + if (this.operatingModeService === undefined) { + // Add in operating mode service for a non-hksv camera/doorbell + // Allow us to change things such as night vision, camera indicator etc within HomeKit for those also:-) + this.operatingModeService = this.accessory.getService(this.hap.Service.CameraOperatingMode); + if (this.operatingModeService === undefined) { + this.operatingModeService = this.accessory.addService(this.hap.Service.CameraOperatingMode, '', 1); + } + } + + // Setup set callbacks for characteristics + if (this.operatingModeService !== undefined) { + if (this.deviceData.has_statusled === true) { + if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator) === false) { + this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator); + } + this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onSet((value) => { + // 0 = auto, 1 = low, 2 = high + // We'll use auto mode for led on and low for led off + if ( + (value === true && this.deviceData.statusled_brightness !== 0) || + (value === false && this.deviceData.statusled_brightness !== 1) + ) { + this.set({ 'statusled.brightness': value === true ? 0 : 1 }); + if (this?.log?.info) { + this.log.info('Recording status LED on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); + } + } + }); + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onGet(() => { + return this.deviceData.statusled_brightness !== 1; + }); + } + + if (this.deviceData.has_irled === true) { + if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.NightVision) === false) { + this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.NightVision); + } + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onSet((value) => { + // only change IRLed status value if different than on-device + if ((value === false && this.deviceData.irled_enabled === true) || (value === true && this.deviceData.irled_enabled === false)) { + this.set({ 'irled.state': value === true ? 'auto_on' : 'always_off' }); + + if (this?.log?.info) { + this.log.info('Night vision on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); + } + } + }); + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onGet(() => { + return this.deviceData.irled_enabled; + }); + } + + if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ManuallyDisabled) === false) { + this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ManuallyDisabled); + } + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.ManuallyDisabled).onSet((value) => { + if (value !== this.operatingModeService.getCharacteristic(this.hap.Characteristic.ManuallyDisabled).value) { + // Make sure only updating status if HomeKit value *actually changes* + if ( + (this.deviceData.streaming_enabled === false && value === false) || + (this.deviceData.streaming_enabled === true && value === true) + ) { + // Camera state does not reflect requested state, so fix + this.set({ 'streaming.enabled': value === false ? true : false }); + if (this?.log?.info) { + this.log.info('Camera on "%s" was turned', this.deviceData.description, value === false ? 'on' : 'off'); + } + } + } + }); + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.ManuallyDisabled).onGet(() => { + return this.deviceData.streaming_enabled === false + ? this.hap.Characteristic.ManuallyDisabled.DISABLED + : this.hap.Characteristic.ManuallyDisabled.ENABLED; + }); + + if (this.deviceData.has_video_flip === true) { + if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ImageRotation) === false) { + this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ImageRotation); + } + + this.operatingModeService.getCharacteristic(this.hap.Characteristic.ImageRotation).onGet(() => { + return this.deviceData.video_flipped === true ? 180 : 0; + }); + } + } + + if (this.controller?.recordingManagement?.recordingManagementService !== undefined) { + if (this.deviceData.has_microphone === true) { + this.controller.recordingManagement.recordingManagementService + .getCharacteristic(this.hap.Characteristic.RecordingAudioActive) + .onSet((value) => { + if ( + (this.deviceData.audio_enabled === true && value === this.hap.Characteristic.RecordingAudioActive.DISABLE) || + (this.deviceData.audio_enabled === false && value === this.hap.Characteristic.RecordingAudioActive.ENABLE) + ) { + this.set({ 'audio.enabled': value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false }); + if (this?.log?.info) { + this.log.info( + 'Audio recording on "%s" was turned', + this.deviceData.description, + value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? 'on' : 'off', + ); + } + } + }); + + this.controller.recordingManagement.recordingManagementService + .getCharacteristic(this.hap.Characteristic.RecordingAudioActive) + .onGet(() => { + return this.deviceData.audio_enabled === true + ? this.hap.Characteristic.RecordingAudioActive.ENABLE + : this.hap.Characteristic.RecordingAudioActive.DISABLE; + }); + } + } // Depending on the streaming profiles that the camera supports, this will be either nexustalk or webrtc // We'll also start pre-buffering if required for HKSV @@ -532,8 +653,8 @@ export default class NestCamera extends HomeKitDevice { // Build response back to HomeKit with the details filled out - // Drop ip module by using small snippet of code below - // Convert ipv4 mapped into ipv6 address into pure ipv4 + // Dropped ip module by using small snippet of code below + // Converts ipv4 mapped into ipv6 address into pure ipv4 if (request.addressVersion === 'ipv4' && request.sourceAddress.startsWith('::ffff:') === true) { request.sourceAddress = request.sourceAddress.replace('::ffff:', ''); } @@ -558,34 +679,26 @@ export default class NestCamera extends HomeKitDevice { } async handleStreamRequest(request, callback) { - // called when HomeKit asks to start/stop/reconfigure a camera/doorbell stream - if (this.streamer === undefined) { + // called when HomeKit asks to start/stop/reconfigure a camera/doorbell live stream + if (request.type === this.hap.StreamRequestTypes.START && this.streamer === undefined) { + // We have no streamer object configured, so cannot do live streams!! this?.log?.error && this.log.error( 'Received request to start live video for "%s" however we do not any associated streaming protocol supported', this.deviceData.description, ); - - if (typeof callback === 'function') { - callback(); // do callback if defined - } - return; } - if (this.deviceData?.ffmpeg?.path === undefined && request.type === this.hap.StreamRequestTypes.START) { + if (request.type === this.hap.StreamRequestTypes.START && this.deviceData?.ffmpeg?.path === undefined) { + // No ffmpeg binary present, so cannot do live streams!! this?.log?.warn && this.log.warn( 'Received request to start live video for "%s" however we do not have an ffmpeg binary present', this.deviceData.description, ); - - if (typeof callback === 'function') { - callback(); // do callback if defined - } - return; } - if (request.type === this.hap.StreamRequestTypes.START) { + if (request.type === this.hap.StreamRequestTypes.START && this.streamer !== undefined && this.deviceData?.ffmpeg?.path !== undefined) { // Build our ffmpeg command string for the liveview video/audio stream let commandLine = '-hide_banner -nostats' + @@ -813,7 +926,7 @@ export default class NestCamera extends HomeKitDevice { ffmpegAudioTalkback?.stdout ? 'with two-way audio' : '', ); - // Start the appropirate streamer + // Start the appropriate streamer this.streamer !== undefined && this.streamer.startLiveStream( request.sessionID, @@ -883,11 +996,11 @@ export default class NestCamera extends HomeKitDevice { if (this.operatingModeService !== undefined) { // Update camera off/on status - // 0 = Enabled - // 1 = Disabled this.operatingModeService.updateCharacteristic( this.hap.Characteristic.ManuallyDisabled, - deviceData.streaming_enabled === true ? 0 : 1, + deviceData.streaming_enabled === false + ? this.hap.Characteristic.ManuallyDisabled.DISABLED + : this.hap.Characteristic.ManuallyDisabled.ENABLED, ); if (deviceData.has_statusled === true && typeof deviceData.statusled_brightness === 'number') { @@ -944,73 +1057,83 @@ export default class NestCamera extends HomeKitDevice { // For HKSV, we're interested motion events // For non-HKSV, we're interested motion, face and person events (maybe sound and package later) deviceData.alerts.forEach((event) => { - // Handle motion event - // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active - if (event.types.includes('motion') === true) { - if (this.motionTimer === undefined && (this.deviceData.hksv === false || this.streamer === undefined)) { - this?.log?.info && this.log.info('Motion detected at "%s"', this.deviceData.description); - } - - event.zone_ids.forEach((zoneID) => { - if ( - typeof this.motionServices?.[zoneID]?.service === 'object' && - this.motionServices[zoneID].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value !== true - ) { - // Trigger motion for matching zone of not aleady active - this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, true); - - // Log motion started into history - if (typeof this.historyService?.addHistory === 'function') { - this.historyService.addHistory(this.motionServices[zoneID].service, { - time: Math.floor(Date.now() / 1000), - status: 1, - }); - } + if ( + this.operatingModeService === undefined || + (this.operatingModeService !== undefined && + this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).value === + this.hap.Characteristic.HomeKitCameraActive.ON) + ) { + // We're configured to handle camera events + // https://github.com/Supereg/secure-video-specification?tab=readme-ov-file#33-homekitcameraactive + + // Handle motion event + // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active + if (event.types.includes('motion') === true) { + if (this.motionTimer === undefined && (this.deviceData.hksv === false || this.streamer === undefined)) { + this?.log?.info && this.log.info('Motion detected at "%s"', deviceData.description); } - }); - // Clear any motion active timer so we can extend if more motion detected - clearTimeout(this.motionTimer); - this.motionTimer = setTimeout(() => { event.zone_ids.forEach((zoneID) => { - if (typeof this.motionServices?.[zoneID]?.service === 'object') { - // Mark associted motion services as motion not detected - this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); + if ( + typeof this.motionServices?.[zoneID]?.service === 'object' && + this.motionServices[zoneID].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value !== true + ) { + // Trigger motion for matching zone of not aleady active + this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, true); // Log motion started into history if (typeof this.historyService?.addHistory === 'function') { this.historyService.addHistory(this.motionServices[zoneID].service, { time: Math.floor(Date.now() / 1000), - status: 0, + status: 1, }); } } }); - this.motionTimer = undefined; // No motion timer active - }, this.deviceData.motionCooldown * 1000); - } + // Clear any motion active timer so we can extend if more motion detected + clearTimeout(this.motionTimer); + this.motionTimer = setTimeout(() => { + event.zone_ids.forEach((zoneID) => { + if (typeof this.motionServices?.[zoneID]?.service === 'object') { + // Mark associted motion services as motion not detected + this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); + + // Log motion started into history + if (typeof this.historyService?.addHistory === 'function') { + this.historyService.addHistory(this.motionServices[zoneID].service, { + time: Math.floor(Date.now() / 1000), + status: 0, + }); + } + } + }); - // Handle person/face event - // We also treat a 'face' event the same as a person event ie: if you have a face, you have a person - if (event.types.includes('person') === true || event.types.includes('face') === true) { - if (this.personTimer === undefined) { - // We don't have a person cooldown timer running, so we can process the 'person'/'face' event - if (this?.log?.info && (this.deviceData.hksv === false || this.streamer === undefined)) { - // We'll only log a person detected event if HKSV is disabled - this.log.info('Person detected at "%s"', this.deviceData.description); - } + this.motionTimer = undefined; // No motion timer active + }, this.deviceData.motionCooldown * 1000); + } + + // Handle person/face event + // We also treat a 'face' event the same as a person event ie: if you have a face, you have a person + if (event.types.includes('person') === true || event.types.includes('face') === true) { + if (this.personTimer === undefined) { + // We don't have a person cooldown timer running, so we can process the 'person'/'face' event + if (this?.log?.info && (this.deviceData.hksv === false || this.streamer === undefined)) { + // We'll only log a person detected event if HKSV is disabled + this.log.info('Person detected at "%s"', deviceData.description); + } - // Cooldown for person being detected - // Start this before we process further - this.personTimer = setTimeout(() => { - this.personTimer = undefined; // No person timer active - }, this.deviceData.personCooldown * 1000); + // Cooldown for person being detected + // Start this before we process further + this.personTimer = setTimeout(() => { + this.personTimer = undefined; // No person timer active + }, this.deviceData.personCooldown * 1000); - if (event.types.includes('motion') === false) { - // If person/face events doesn't include a motion event, add in here - // This will handle all the motion triggering stuff - event.types.push('motion'); + if (event.types.includes('motion') === false) { + // If person/face events doesn't include a motion event, add in here + // This will handle all the motion triggering stuff + event.types.push('motion'); + } } } } @@ -1044,143 +1167,6 @@ export default class NestCamera extends HomeKitDevice { } } - createCameraServices() { - if (this.controller === undefined) { - return; - } - - this.operatingModeService = this.controller?.recordingManagement?.operatingModeService; - if (this.operatingModeService === undefined) { - // Add in operating mode service for a non-hksv camera/doorbell - // Allow us to change things such as night vision, camera indicator etc within HomeKit for those also:-) - this.operatingModeService = this.accessory.getService(this.hap.Service.CameraOperatingMode); - if (this.operatingModeService === undefined) { - this.operatingModeService = this.accessory.addService(this.hap.Service.CameraOperatingMode, '', 1); - } - } - - // Setup set callbacks for characteristics - if (this.deviceData.has_statusled === true && this.operatingModeService !== undefined) { - if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator) === false) { - this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator); - } - this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onSet((value) => { - // 0 = auto, 1 = low, 2 = high - // We'll use auto mode for led on and low for led off - if ( - (value === true && this.deviceData.statusled_brightness !== 0) || - (value === false && this.deviceData.statusled_brightness !== 1) - ) { - this.set({ 'statusled.brightness': value === true ? 0 : 1 }); - if (this?.log?.info) { - this.log.info('Recording status LED on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); - } - } - }); - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onGet(() => { - return this.deviceData.statusled_brightness !== 1; - }); - } - - if (this.deviceData.has_irled === true && this.operatingModeService !== undefined) { - if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.NightVision) === false) { - this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.NightVision); - } - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onSet((value) => { - // only change IRLed status value if different than on-device - if ((value === false && this.deviceData.irled_enabled === true) || (value === true && this.deviceData.irled_enabled === false)) { - this.set({ 'irled.state': value === true ? 'auto_on' : 'always_off' }); - - if (this?.log?.info) { - this.log.info('Night vision on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); - } - } - }); - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onGet(() => { - return this.deviceData.irled_enabled; - }); - } - - if (this.operatingModeService !== undefined) { - this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).onSet((value) => { - if (value !== this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).value) { - // Make sure only updating status if HomeKit value *actually changes* - if ( - (this.deviceData.streaming_enabled === false && value === this.hap.Characteristic.HomeKitCameraActive.ON) || - (this.deviceData.streaming_enabled === true && value === this.hap.Characteristic.HomeKitCameraActive.OFF) - ) { - // Camera state does not reflect requested state, so fix - this.set({ 'streaming.enabled': value === this.hap.Characteristic.HomeKitCameraActive.ON ? true : false }); - if (this.log.info) { - this.log.info( - 'Camera on "%s" was turned', - this.deviceData.description, - value === this.hap.Characteristic.HomeKitCameraActive.ON ? 'on' : 'off', - ); - } - } - } - }); - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).onGet(() => { - return this.deviceData.streaming_enabled === true - ? this.hap.Characteristic.HomeKitCameraActive.ON - : this.hap.Characteristic.HomeKitCameraActive.OFF; - }); - } - - if (this.deviceData.has_video_flip === true && this.operatingModeService !== undefined) { - if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ImageRotation) === false) { - this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ImageRotation); - } - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.ImageRotation).onGet(() => { - return this.deviceData.video_flipped === true ? 180 : 0; - }); - } - - if (this.deviceData.has_irled === true && this.operatingModeService !== undefined) { - if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ManuallyDisabled) === false) { - this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ManuallyDisabled); - } - - this.operatingModeService.getCharacteristic(this.hap.Characteristic.ManuallyDisabled).onGet(() => { - return this.deviceData.streaming_enabled === true ? 0 : 1; - }); - } - - if (this.deviceData.has_microphone === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) { - this.controller.recordingManagement.recordingManagementService - .getCharacteristic(this.hap.Characteristic.RecordingAudioActive) - .onSet((value) => { - if ( - (this.deviceData.audio_enabled === true && value === this.hap.Characteristic.RecordingAudioActive.DISABLE) || - (this.deviceData.audio_enabled === false && value === this.hap.Characteristic.RecordingAudioActive.ENABLE) - ) { - this.set({ 'audio.enabled': value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false }); - if (this?.log?.info) { - this.log.info( - 'Audio recording on "%s" was turned', - this.deviceData.description, - value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? 'on' : 'off', - ); - } - } - }); - - this.controller.recordingManagement.recordingManagementService - .getCharacteristic(this.hap.Characteristic.RecordingAudioActive) - .onGet(() => { - return this.deviceData.audio_enabled === true - ? this.hap.Characteristic.RecordingAudioActive.ENABLE - : this.hap.Characteristic.RecordingAudioActive.DISABLE; - }); - } - } - generateControllerOptions() { // Setup HomeKit controller camera/doorbell options let controllerOptions = { @@ -1213,27 +1199,25 @@ export default class NestCamera extends HomeKitDevice { levels: [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0], }, }, - audio: undefined, + audio: { + twoWayAudio: + this.deviceData?.ffmpeg?.libfdk_aac === true && + this.deviceData?.ffmpeg?.libspeex === true && + this.deviceData.has_speaker === true && + this.deviceData.has_microphone === true, + codecs: [ + { + type: this.hap.AudioStreamingCodecType.AAC_ELD, + samplerate: this.hap.AudioStreamingSamplerate.KHZ_16, + audioChannel: 1, + }, + ], + }, }, recording: undefined, sensors: undefined, }; - if (this.deviceData?.ffmpeg?.libfdk_aac === true) { - // Enabling audio for streaming if we have the appropriate codec in ffmpeg binary present - controllerOptions.streamingOptions.audio = { - twoWayAudio: - this.deviceData?.ffmpeg?.libspeex === true && this.deviceData.has_speaker === true && this.deviceData.has_microphone === true, - codecs: [ - { - type: this.hap.AudioStreamingCodecType.AAC_ELD, - samplerate: this.hap.AudioStreamingSamplerate.KHZ_16, - audioChannel: 1, - }, - ], - }; - } - if (this.deviceData.hksv === true) { controllerOptions.recording = { delegate: this, diff --git a/src/doorbell.js b/src/doorbell.js index 5e92c94..2cb49a8 100644 --- a/src/doorbell.js +++ b/src/doorbell.js @@ -1,7 +1,7 @@ // Nest Doorbell(s) // Part of homebridge-nest-accfactory // -// Code version 6/9/2024 +// Code version 8/9/2024 // Mark Hulskamp 'use strict'; @@ -97,11 +97,11 @@ export default class NestDoorbell extends NestCamera { if (deviceData.indoor_chime_enabled === false || deviceData.quiet_time_enabled === true) { // Indoor chime is disabled or quiet time is enabled, so we won't 'ring' the doorbell - this?.log?.warn && this.log.warn('Doorbell rung at "%s" but indoor chime is silenced', this.deviceData.description); + this?.log?.warn && this.log.warn('Doorbell rung at "%s" but indoor chime is silenced', deviceData.description); } if (deviceData.indoor_chime_enabled === true && deviceData.quiet_time_enabled === false) { // Indoor chime is enabled and quiet time isn't enabled, so 'ring' the doorbell - this?.log?.info && this.log.info('Doorbell rung at "%s"', this.deviceData.description); + this?.log?.info && this.log.info('Doorbell rung at "%s"', deviceData.description); this.controller.ringDoorbell(); } diff --git a/src/floodlight.js b/src/floodlight.js new file mode 100644 index 0000000..73eab1a --- /dev/null +++ b/src/floodlight.js @@ -0,0 +1,97 @@ +// Nest Cam with Floodlight +// Part of homebridge-nest-accfactory +// +// Code version 12/9/2024 +// Mark Hulskamp +'use strict'; + +// Define external module requirements +import NestCamera from './camera.js'; + +export default class NestFloodlight extends NestCamera { + lightService = undefined; // HomeKit light + + constructor(accessory, api, log, eventEmitter, deviceData) { + super(accessory, api, log, eventEmitter, deviceData); + } + + // Class functions + addServices() { + // Call parent to setup the common camera things. Once we return, we can add in the specifics for our floodlight + let postSetupDetails = super.addServices(); + + this.lightService = this.accessory.getService(this.hap.Service.Switch); + if (this.deviceData.has_light === true) { + // Add service to for a light, including brightness control + if (this.lightService === undefined) { + this.lightService = this.accessory.addService(this.hap.Service.Lightbulb, '', 1); + } + + if (this.lightService.testCharacteristic(this.hap.Characteristic.Brightness) === false) { + this.lightService.addCharacteristic(this.hap.Characteristic.Brightness); + } + + this.lightService.getCharacteristic(this.hap.Characteristic.Brightness).setProps({ + minStep: 10, // Light only goes in 10% increments + }); + + // Setup set callback for this light service + this.lightService.getCharacteristic(this.hap.Characteristic.On).onSet((value) => { + if (value !== this.deviceData.light_enabled) { + this.set({ light_enabled: value }); + + this?.log?.info && this.log.info('Floodlight on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); + } + }); + + this.lightService.getCharacteristic(this.hap.Characteristic.Brightness).onSet((value) => { + if (value !== this.deviceData.light_brightness) { + this.set({ light_brightness: value }); + + this?.log?.info && this.log.info('Floodlight brightness on "%s" was set to "%s %"', this.deviceData.description); + } + }); + + this.lightService.getCharacteristic(this.hap.Characteristic.On).onGet(() => { + return this.deviceData.light_enabled === true; + }); + + this.lightService.getCharacteristic(this.hap.Characteristic.Brightness).onGet(() => { + return this.deviceData.light_brightness; + }); + } + if (this.lightService !== undefined && this.deviceData.has_light !== true) { + // No longer required to have the light service + this.accessory.removeService(this.lightService); + this.lightService === undefined; + } + + // Create extra details for output + this.lightService !== undefined && postSetupDetails.push('Light support'); + return postSetupDetails; + } + + removeServices() { + super.removeServices(); + + if (this.lightService !== undefined) { + this.accessory.removeService(this.lightService); + } + this.lightService = undefined; + } + + updateServices(deviceData) { + if (typeof deviceData !== 'object' || this.controller === undefined) { + return; + } + + // Get the camera class todo all its updates first, then we'll handle the doorbell specific stuff + super.updateServices(deviceData); + + if (this.lightService !== undefined) { + // Update status of light, including brightness + this.lightService.updateCharacteristic(this.hap.Characteristic.On, deviceData.light_enabled); + this.lightService.updateCharacteristic(this.hap.Characteristic.Brightness, deviceData.light_brightness); + } + } +} diff --git a/src/index.js b/src/index.js index c4d3945..a447471 100644 --- a/src/index.js +++ b/src/index.js @@ -5,18 +5,18 @@ // // The following Nest devices are supported // -// Nest Thermostats (Gen 1, Gen 2, Gen 3, E, Mirrored 2020) -// Nest Protects (Gen 1, Gen 2) -// Nest Temperature Sensors -// Nest Cameras (Cam Indoor, IQ Indoor, Outdoor, IQ Outdoor) -// Nest Hello (Wired Gen 1) +// Nest Thermostats (1st gen, 2nd gen, 3rd gen, E, 2020 mirror edition, 4th gen) +// Nest Protects (1st and 2nd gen) +// Nest Temp Sensors (1st gen) +// Nest Cameras (Cam Indoor, IQ Indoor, Outdoor, IQ Outdoor, Cam with Floodlight) +// Nest Doorbells (wired 1st gen) // // The accessory supports authentication to Nest/Google using either a Nest account OR Google (migrated Nest account) account. // 'preliminary' support for using FieldTest account types also. // // Supports both Nest REST and protobuf APIs for communication to Nest systems // -// Code version 20/8/2024 +// Code version 11/9/2024 // Mark Hulskamp 'use strict'; @@ -225,7 +225,7 @@ function loadConfiguration(filename) { config.devices[key]['personCooldown'] = value; } if (subKey.startsWith('External') === true && typeof value === 'string' && value !== '') { - config.devices[key]['external' + subKey.substring(6)] = value; + config.devices[key]['external' + subKey.substring(8)] = value; } }); } diff --git a/src/nexustalk.js b/src/nexustalk.js index ac54c50..e3335d1 100644 --- a/src/nexustalk.js +++ b/src/nexustalk.js @@ -3,61 +3,31 @@ // // Handles connection and data from Nest 'nexus' systems // -// Code version 6/9/2024 +// Credit to https://github.com/Brandawg93/homebridge-nest-cam for the work on the Nest Camera comms code on which this is based +// +// Code version 11/9/2024 // Mark Hulskamp 'use strict'; // Define external library requirements -import protoBuf from 'pbf'; // Proto buffer +import protobuf from 'protobufjs'; // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers'; +import fs from 'node:fs'; +import path from 'node:path'; import tls from 'tls'; import crypto from 'crypto'; +import { fileURLToPath } from 'node:url'; // Define our modules import Streamer from './streamer.js'; // Define constants const PINGINTERVAL = 15000; // Ping interval to nexus server while stream active - -const CodecType = { - SPEEX: 0, - PCM_S16_LE: 1, - H264: 2, - AAC: 3, - OPUS: 4, - META: 5, - DIRECTORS_CUT: 6, -}; - -const StreamProfile = { - AVPROFILE_MOBILE_1: 1, - AVPROFILE_HD_MAIN_1: 2, - AUDIO_AAC: 3, - AUDIO_SPEEX: 4, - AUDIO_OPUS: 5, - VIDEO_H264_50KBIT_L12: 6, - VIDEO_H264_530KBIT_L31: 7, - VIDEO_H264_100KBIT_L30: 8, - VIDEO_H264_2MBIT_L40: 9, - VIDEO_H264_50KBIT_L12_THUMBNAIL: 10, - META: 11, - DIRECTORS_CUT: 12, - AUDIO_OPUS_LIVE: 13, - VIDEO_H264_L31: 14, - VIDEO_H264_L40: 15, -}; - -const ErrorCode = { - ERROR_CAMERA_NOT_CONNECTED: 1, - ERROR_ILLEGAL_PACKET: 2, - ERROR_AUTHORIZATION_FAILED: 3, - ERROR_NO_TRANSCODER_AVAILABLE: 4, - ERROR_TRANSCODE_PROXY_ERROR: 5, - ERROR_INTERNAL: 6, -}; +const USERAGENT = '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 PacketType = { PING: 1, @@ -85,48 +55,36 @@ const PacketType = { AUTHORIZE_REQUEST: 212, }; -const ProtocolVersion = { - VERSION_1: 1, - VERSION_2: 2, - VERSION_3: 3, -}; - -const ClientType = { - ANDROID: 1, - IOS: 2, - WEB: 3, -}; - // nexusTalk object export default class NexusTalk extends Streamer { token = undefined; tokenType = undefined; - uuid = undefined; id = undefined; // Session ID - authorised = false; // Have wee been authorised pingTimer = undefined; // Timer object for ping interval stalledTimer = undefined; // Timer object for no received data - packets = []; // Incoming packets - messages = []; // Incoming messages video = {}; // Video stream details audio = {}; // Audio stream details + // Internal data only for this class + #protobufNexusTalk = undefined; // Protobuf for NexusTalk + #socket = undefined; // TCP socket object + #packets = []; // Incoming packets + #messages = []; // Incoming messages + #authorised = false; // Have we been authorised + constructor(deviceData, options) { super(deviceData, options); - // Store data we need from the device data passed it - this.token = deviceData?.apiAccess.token; - if (deviceData?.apiAccess?.key === 'Authorization') { - this.tokenType = 'google'; - } - if (deviceData?.apiAccess?.key === 'cookie') { - this.tokenType = 'nest'; + if (fs.existsSync(path.resolve(__dirname + '/protobuf/nest/nexustalk.proto')) === true) { + protobuf.util.Long = null; + protobuf.configure(); + this.#protobufNexusTalk = protobuf.loadSync(path.resolve(__dirname + '/protobuf/nest/nexustalk.proto')); } - this.uuid = deviceData?.uuid; - this.host = deviceData?.streaming_host; // Host we'll connect to - this.pendingHost = null; - this.weDidClose = true; // Flag if we did the socket close gracefully + // Store data we need from the device data passed it + this.token = deviceData?.apiAccess?.token; + this.tokenType = deviceData?.apiAccess?.oauth2 !== undefined ? 'google' : 'nest'; + this.host = deviceData?.streaming_host; // Host we'll connect to // If specified option to start buffering, kick off if (typeof options?.buffer === 'boolean' && options.buffer === true) { @@ -148,47 +106,37 @@ export default class NexusTalk extends Streamer { host = this.host; } - if (this.pendingHost !== null) { - host = this.pendingHost; - this.pendingHost = null; - } - this?.log?.debug && this.log.debug('Starting connection to "%s"', host); - this.socket = tls.connect({ host: host, port: 1443 }, () => { + this.#socket = tls.connect({ host: host, port: 1443 }, () => { // Opened connection to Nexus server, so now need to authenticate ourselves this?.log?.debug && this.log.debug('Connection established to "%s"', host); - this.socket.setKeepAlive(true); // Keep socket connection alive + this.#socket.setKeepAlive(true); // Keep socket connection alive this.host = host; // update internal host name since we've connected + this.connected = true; this.#Authenticate(false); }); - this.socket.on('error', () => {}); + this.#socket.on('error', () => {}); - this.socket.on('end', () => {}); + this.#socket.on('end', () => {}); - this.socket.on('data', (data) => { + this.#socket.on('data', (data) => { this.#handleNexusData(data); }); - this.socket.on('close', (hadError) => { - if (hadError === true) { - // - } - let normalClose = this.weDidClose; // Cache this, so can reset it below before we take action + this.#socket.on('close', (hadError) => { + this?.log?.debug && this.log.debug('Connection closed to "%s"', host); this.stalledTimer = clearTimeout(this.stalledTimer); // Clear stalled timer this.pingTimer = clearInterval(this.pingTimer); // Clear ping timer - this.authorised = false; // Since connection close, we can't be authorised anymore - this.socket = null; // Clear socket object + this.#authorised = false; // Since connection close, we can't be authorised anymore + this.#socket = undefined; // Clear socket object + this.connected = false; this.id = undefined; // Not an active session anymore - this.weDidClose = false; // Reset closed flag - - this?.log?.debug && this.log.debug('Connection closed to "%s"', host); - - if (normalClose === false && this.haveOutputs() === true) { + if (hadError === true && this.haveOutputs() === true) { // We still have either active buffering occuring or output streams running // so attempt to restart connection to existing host this.connect(host); @@ -199,20 +147,19 @@ export default class NexusTalk extends Streamer { close(stopStreamFirst) { // Close an authenicated socket stream gracefully - if (this.socket !== null) { + if (this.#socket !== null) { if (stopStreamFirst === true) { // Send a notifcation to nexus we're finished playback this.#stopNexusData(); } - this.socket.destroy(); + this.#socket.destroy(); } - this.socket = null; + this.connected = false; + this.#socket = undefined; this.id = undefined; // Not an active session anymore - this.packets = []; - this.messages = []; - - this.weDidClose = true; // Flag we did the socket close + this.#packets = []; + this.#messages = []; } update(deviceData) { @@ -224,7 +171,7 @@ export default class NexusTalk extends Streamer { // access token has changed so re-authorise this.token = deviceData.apiAccess.token; - if (this.socket !== null) { + if (this.#socket !== null) { this.#Authenticate(true); // Update authorisation only if connected } } @@ -235,51 +182,68 @@ export default class NexusTalk extends Streamer { talkingAudio(talkingData) { // Encode audio packet for sending to camera - let audioBuffer = new protoBuf(); - audioBuffer.writeBytesField(1, talkingData); // audio data - audioBuffer.writeVarintField(2, this.id); // session ID - audioBuffer.writeVarintField(3, CodecType.SPEEX); // codec - audioBuffer.writeVarintField(4, 16000); // sample rate, 16k - //audioBuffer.writeVarintField(5, ????); // Latency measure tag. What does this do? - this.#sendMessage(PacketType.AUDIO_PAYLOAD, audioBuffer.finish()); + if (typeof talkingData === 'object' && this.#protobufNexusTalk !== undefined) { + let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.StartPlayback'); + if (TraitMap !== null) { + let encodedData = TraitMap.encode( + TraitMap.fromObject({ + payload: talkingData, + sessionId: this.id, + codec: 'SPEEX', + sampleRate: 16000, + }), + ).finish(); + this.#sendMessage(PacketType.AUDIO_PAYLOAD, encodedData); + } + } } #startNexusData() { - if (this.videoEnabled === false || this.online === false) { + if (this.videoEnabled === false || this.online === false || this.#protobufNexusTalk === undefined) { return; } // Setup streaming profiles // We'll use the highest profile as the main, with others for fallback - let otherProfiles = []; - otherProfiles.push(StreamProfile.VIDEO_H264_530KBIT_L31); // Medium quality - otherProfiles.push(StreamProfile.VIDEO_H264_100KBIT_L30); // Low quality + let otherProfiles = ['VIDEO_H264_530KBIT_L31', 'VIDEO_H264_100KBIT_L30']; if (this.audioEnabled === true) { // Include AAC profile if audio is enabled on camera - otherProfiles.push(StreamProfile.AUDIO_AAC); + otherProfiles.push('AUDIO_AAC'); } - let startBuffer = new protoBuf(); - startBuffer.writeVarintField(1, Math.floor(Math.random() * (100 - 1) + 1)); // Random session ID between 1 and 100); - startBuffer.writeVarintField(2, StreamProfile.VIDEO_H264_2MBIT_L40); // Default profile. ie: high quality - otherProfiles.forEach((otherProfile) => { - startBuffer.writeVarintField(6, otherProfile); // Other supported profiles - }); - - this.#sendMessage(PacketType.START_PLAYBACK, startBuffer.finish()); + let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.StartPlayback'); + if (TraitMap !== null) { + let encodedData = TraitMap.encode( + TraitMap.fromObject({ + sessionId: Math.floor(Math.random() * (100 - 1) + 1), + profile: 'VIDEO_H264_2MBIT_L40', + otherProfiles: otherProfiles, + profileNotFoundAction: 'REDIRECT', + }), + ).finish(); + this.#sendMessage(PacketType.START_PLAYBACK, encodedData); + } } #stopNexusData() { - let stopBuffer = new protoBuf(); - stopBuffer.writeVarintField(1, this.id); // Session ID - this.#sendMessage(PacketType.STOP_PLAYBACK, stopBuffer.finish()); + if (this.id !== undefined && this.#protobufNexusTalk !== undefined) { + let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.StopPlayback'); + if (TraitMap !== null) { + let encodedData = TraitMap.encode( + TraitMap.fromObject({ + sessionId: this.id, + }), + ).finish(); + this.#sendMessage(PacketType.STOP_PLAYBACK, encodedData); + } + } } #sendMessage(type, data) { - if (this.socket === null || this.socket.readyState !== 'open' || (type !== PacketType.HELLO && this.authorised === false)) { + if (this.#socket === null || this.#socket.readyState !== 'open' || (type !== PacketType.HELLO && this.#authorised === false)) { // We're not connect and/or authorised yet, so 'cache' message for processing once this occurs - this.messages.push({ type: type, data: data }); + this.#messages.push({ type: type, data: data }); return; } @@ -296,61 +260,60 @@ export default class NexusTalk extends Streamer { } // write our composed message out to the socket back to NexusTalk - this.socket.write(Buffer.concat([header, Buffer.from(data)]), () => { + this.#socket.write(Buffer.concat([header, Buffer.from(data)]), () => { // Message sent. Don't do anything? }); } #Authenticate(reauthorise) { // Authenticate over created socket connection - let tokenBuffer = new protoBuf(); - let helloBuffer = new protoBuf(); + if (this.#protobufNexusTalk !== undefined) { + this.#authorised = false; // We're nolonger authorised + + let authoriseRequest = null; + let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.AuthoriseRequest'); + if (TraitMap !== null) { + authoriseRequest = TraitMap.encode( + TraitMap.fromObject( + this.tokenType === 'nest' ? { sessionToken: this.token } : this.tokenType === 'google' ? { oliveToken: this.token } : {}, + ), + ).finish(); + } - this.authorised = false; // We're nolonger authorised + if (reauthorise === true && authoriseRequest !== null) { + // Request to re-authorise only + this?.log?.debug && this.log.debug('Re-authentication requested to "%s"', this.host); + this.#sendMessage(PacketType.AUTHORIZE_REQUEST, authoriseRequest); + } - if (this.tokenType === 'nest') { - tokenBuffer.writeStringField(1, this.token); // Tag 1, session token, Nest auth accounts - helloBuffer.writeStringField(4, this.token); // Tag 4, session token, Nest auth accounts - } - if (this.tokenType === 'google') { - tokenBuffer.writeStringField(4, this.token); // Tag 4, olive token, Google auth accounts - helloBuffer.writeBytesField(12, tokenBuffer.finish()); // Tag 12, olive token, Google auth accounts - } - if (typeof reauthorise === 'boolean' && reauthorise === true) { - // Request to re-authorise only - this?.log?.debug && this.log.debug('Re-authentication requested to "%s"', this.host); - this.#sendMessage(PacketType.AUTHORIZE_REQUEST, tokenBuffer.finish()); - } else { - // This isn't a re-authorise request, so perform 'Hello' packet - this?.log?.debug && this.log.debug('Performing authentication to "%s"', this.host); - helloBuffer.writeVarintField(1, ProtocolVersion.VERSION_3); - helloBuffer.writeStringField(2, this.uuid.split('.')[1]); // UUID should be 'quartz.xxxxxx'. We want the xxxxxx part - helloBuffer.writeBooleanField(3, false); // Doesnt required a connected camera - helloBuffer.writeStringField(6, crypto.randomUUID()); // Random UUID for this connection attempt - helloBuffer.writeStringField(7, 'Nest/5.75.0 (iOScom.nestlabs.jasper.release) os=17.4.1'); - helloBuffer.writeVarintField(9, ClientType.IOS); - this.#sendMessage(PacketType.HELLO, helloBuffer.finish()); + if (reauthorise === false && authoriseRequest !== null) { + // This isn't a re-authorise request, so perform 'Hello' packet + let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.Hello'); + if (TraitMap !== null) { + this?.log?.debug && this.log.debug('Performing authentication to "%s"', this.host); + + let encodedData = TraitMap.encode( + TraitMap.fromObject({ + protocolVersion: 'VERSION_3', + uuid: this.uuid.split(/[._]+/)[1], + requireConnectedCamera: false, + userAgent: USERAGENT, + deviceId: crypto.randomUUID(), + ClientType: 'IOS', + authoriseRequest: authoriseRequest, + }), + ).finish(); + this.#sendMessage(PacketType.HELLO, encodedData); + } + } } } #handleRedirect(payload) { let redirectToHost = undefined; - if (typeof payload === 'object') { - // Payload parameter is an object, we'll assume its a payload packet - // Decode redirect packet to determine new host - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.new_host = protoBuf.readString(); // new host - } - if (tag === 2) { - obj.is_transcode = protoBuf.readBoolean(); - } - }, - { new_host: '', is_transcode: false }, - ); - - redirectToHost = packet.new_host; + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.Redirect').decode(payload).toJSON(); + redirectToHost = decodedMessage?.newHost; } if (typeof payload === 'string') { // Payload parameter is a string, we'll assume this is a direct hostname @@ -364,316 +327,157 @@ export default class NexusTalk extends Streamer { this?.log?.debug && this.log.debug('Redirect requested from "%s" to "%s"', this.host, redirectToHost); // Setup listener for socket close event. Once socket is closed, we'll perform the redirect - this.socket && - this.socket.on('close', () => { + this.#socket && + this.#socket.on('close', () => { this.connect(redirectToHost); // Connect to new host }); this.close(true); // Close existing socket } #handlePlaybackBegin(payload) { - // Decode playback begin packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.session_id = protoBuf.readVarint(); - } - if (tag === 2) { - obj.channels.push( - protoBuf.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.channel_id = protoBuf.readVarint(); - } - if (tag === 2) { - obj.codec_type = protoBuf.readVarint(); - } - if (tag === 3) { - obj.sample_rate = protoBuf.readVarint(); - } - if (tag === 4) { - obj.private_data.push(protoBuf.readBytes()); - } - if (tag === 5) { - obj.start_time = protoBuf.readDouble(); - } - if (tag === 6) { - obj.udp_ssrc = protoBuf.readVarint(); - } - if (tag === 7) { - obj.rtp_start_time = protoBuf.readVarint(); - } - if (tag === 8) { - obj.profile = protoBuf.readVarint(); - } - }, - { channel_id: 0, codec_type: 0, sample_rate: 0, private_data: [], start_time: 0, udp_ssrc: 0, rtp_start_time: 0, profile: 3 }, - protoBuf.readVarint() + protoBuf.pos, - ), - ); - } - if (tag === 3) { - obj.srtp_master_key = protoBuf.readBytes(); - } - if (tag === 4) { - obj.srtp_master_salt = protoBuf.readBytes(); - } - if (tag === 5) { - obj.fec_k_val = protoBuf.readVarint(); - } - if (tag === 6) { - obj.fec_n_val = protoBuf.readVarint(); - } - }, - { session_id: 0, channels: [], srtp_master_key: null, srtp_master_salt: null, fec_k_val: 0, fec_n_val: 0 }, - ); - - packet.channels && - packet.channels.forEach((stream) => { + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.PlaybackBegin').decode(payload).toJSON(); + decodedMessage.channels.forEach((stream) => { // Find which channels match our video and audio streams - if (stream.codec_type === CodecType.H264) { + if (stream.codecType === 'H264') { this.video = { - channel_id: stream.channel_id, - start_time: Date.now() + stream.start_time, - sample_rate: stream.sample_rate, + channel_id: stream.channelId, + start_time: Date.now() + stream.startTime, + sample_rate: stream.sampleRate, timestamp_delta: 0, }; } - if (stream.codec_type === CodecType.AAC || stream.codec_type === CodecType.OPUS || stream.codec_type === CodecType.SPEEX) { + if (stream.codecType === 'AAC' || stream.codecType === 'OPUS' || stream.codecType === 'SPEEX') { this.audio = { - channel_id: stream.channel_id, - start_time: Date.now() + stream.start_time, - sample_rate: stream.sample_rate, + channel_id: stream.channelId, + start_time: Date.now() + stream.startTime, + sample_rate: stream.sampleRate, timestamp_delta: 0, }; } }); - // Since this is the beginning of playback, clear any active buffers contents - this.id = packet.session_id; - this.packets = []; - this.messages = []; + // Since this is the beginning of playback, clear any active buffers contents + this.id = decodedMessage.sessionId; + this.#packets = []; + this.#messages = []; - this?.log?.debug && this.log.debug('Playback started from "%s" with session ID "%s"', this.host, this.id); + this?.log?.debug && this.log.debug('Playback started from "%s" with session ID "%s"', this.host, this.id); + } } #handlePlaybackPacket(payload) { // Decode playback packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.session_id = protoBuf.readVarint(); - } - if (tag === 2) { - obj.channel_id = protoBuf.readVarint(); - } - if (tag === 3) { - obj.timestamp_delta = protoBuf.readSVarint(); - } - if (tag === 4) { - obj.payload = protoBuf.readBytes(); - } - if (tag === 5) { - obj.latency_rtp_sequence = protoBuf.readVarint(); - } - if (tag === 6) { - obj.latency_rtp_ssrc = protoBuf.readVarint(); - } - if (tag === 7) { - obj.directors_cut_regions.push( - protoBuf.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.id = protoBuf.readVarint(); - } - if (tag === 2) { - obj.left = protoBuf.readVarint(); - } - if (tag === 3) { - obj.right = protoBuf.readVarint(); - } - if (tag === 4) { - obj.top = protoBuf.readVarint(); - } - if (tag === 5) { - obj.bottom = protoBuf.readVarint(); - } - }, - { - // Defaults - id: 0, - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - protoBuf.readVarint() + protoBuf.pos, - ), - ); - } - }, - { - // Defaults - session_id: 0, - channel_id: 0, - timestamp_delta: 0, - payload: null, - latency_rtp_sequence: 0, - latency_rtp_ssrc: 0, - directors_cut_regions: [], - }, - ); - - // Setup up a timeout to monitor for no packets recieved in a certain period - // If its trigger, we'll attempt to restart the stream and/or connection - // <-- testing to see how often this occurs first - this.stalledTimer = clearTimeout(this.stalledTimer); - this.stalledTimer = setTimeout(() => { - this?.log?.debug && this.log.debug('We have not received any data from nexus in the past "%s" seconds. Attempting restart', 8); - - // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection - this.socket && - this.socket.on('close', () => { - this.connect(this.host); // try reconnection - }); - this.close(false); // Close existing socket - }, 8000); - - // Handle video packet - if (packet.channel_id === this.video.channel_id) { - this.video.timestamp_delta += packet.timestamp_delta; - this.addToOutput('video', this.video.start_time + this.video.timestamp_delta, packet.payload); - } + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.PlaybackPacket').decode(payload).toJSON(); + + // Setup up a timeout to monitor for no packets recieved in a certain period + // If its trigger, we'll attempt to restart the stream and/or connection + // <-- testing to see how often this occurs first + this.stalledTimer = clearTimeout(this.stalledTimer); + this.stalledTimer = setTimeout(() => { + this?.log?.debug && this.log.debug('We have not received any data from nexus in the past "%s" seconds. Attempting restart', 8); + + // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection + this.#socket && + this.#socket.on('close', () => { + this.connect(this.host); // try reconnection + }); + this.close(false); // Close existing socket + }, 8000); + + // Handle video packet + if (decodedMessage.channelId === this.video.channel_id) { + this.video.timestamp_delta += decodedMessage.timestampDelta; + this.addToOutput('video', this.video.start_time + this.video.timestamp_delta, Buffer.from(decodedMessage.payload, 'base64')); + } - // Handle audio packet - if (packet.channel_id === this.audio.channel_id) { - this.audio.timestamp_delta += packet.timestamp_delta; - this.addToOutput('audio', this.audio.start_time + this.audio.timestamp_delta, packet.payload); + // Handle audio packet + if (decodedMessage.channelId === this.audio.channel_id) { + this.audio.timestamp_delta += decodedMessage.timestampDelta; + this.addToOutput('audio', this.audio.start_time + this.audio.timestamp_delta, Buffer.from(decodedMessage.payload, 'base64')); + } } } #handlePlaybackEnd(payload) { // Decode playpack ended packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.session_id = protoBuf.readVarint(); - } - if (tag === 2) { - obj.reason = protoBuf.readVarint(); - } - }, - { session_id: 0, reason: 0 }, - ); + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.PlaybackEnd').decode(payload).toJSON(); - if (this.id !== null && packet.reason === 0) { - // Normal playback ended ie: when we stopped playback - this?.log?.debug && this.log.debug('Playback ended on "%s"', this.host); - } - - if (packet.reason !== 0) { - // Error during playback, so we'll attempt to restart by reconnection to host - this?.log?.debug && this.log.debug('Playback ended on "%s" with error "%s". Attempting reconnection', this.host, packet.reason); + if (this.id !== null && decodedMessage.reason === 'USER_ENDED_SESSION') { + // Normal playback ended ie: when we stopped playback + this?.log?.debug && this.log.debug('Playback ended on "%s"', this.host); + } - // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection - this.socket && - this.socket.on('close', () => { - this.connect(this.host); // try reconnection to existing host - }); - this.close(false); // Close existing socket + if (decodedMessage.reason !== 'USER_ENDED_SESSION') { + // Error during playback, so we'll attempt to restart by reconnection to host + this?.log?.debug && + this.log.debug('Playback ended on "%s" with error "%s". Attempting reconnection', this.host, decodedMessage.reason); + + // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection + this.#socket && + this.#socket.on('close', () => { + this.connect(this.host); // try reconnection to existing host + }); + this.close(false); // Close existing socket + } } } #handleNexusError(payload) { // Decode error packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.code = protoBuf.readVarint(); - } - if (tag === 2) { - obj.message = protoBuf.readString(); - } - }, - { code: 1, message: '' }, - ); - - if (packet.code === ErrorCode.ERROR_AUTHORIZATION_FAILED) { - // NexusStreamer Updating authentication - this.#Authenticate(true); // Update authorisation only - } else { - // NexusStreamer Error, packet.message contains the message - this?.log?.debug && this.log.debug('Error', packet.message); + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.Error').decode(payload).toJSON(); + if (decodedMessage.code === 'ERROR_AUTHORIZATION_FAILED') { + // NexusStreamer Updating authentication + this.#Authenticate(true); // Update authorisation only + } else { + // NexusStreamer Error, packet.message contains the message + this?.log?.debug && this.log.debug('Error', decodedMessage.message); + } } } #handleTalkbackBegin(payload) { // Decode talk begin packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.user_id = protoBuf.readString(); - } - if (tag === 2) { - obj.session_id = protoBuf.readVarint(); - } - if (tag === 3) { - obj.quick_action_id = protoBuf.readVarint(); - } - if (tag === 4) { - obj.device_id = protoBuf.readString(); - } - }, - { user_id: '', session_id: 0, quick_action_id: 0, device_id: '' }, - ); - - this?.log?.debug && this.log.debug('Talkback started on "%s"', packet.device_id); + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.TalkbackBegin').decode(payload).toJSON(); + this?.log?.debug && this.log.debug('Talkback started on "%s"', decodedMessage.deviceId); + } } #handleTalkbackEnd(payload) { // Decode talk end packet - let packet = payload.readFields( - (tag, obj, protoBuf) => { - if (tag === 1) { - obj.user_id = protoBuf.readString(); - } - if (tag === 2) { - obj.session_id = protoBuf.readVarint(); - } - if (tag === 3) { - obj.quick_action_id = protoBuf.readVarint(); - } - if (tag === 4) { - obj.device_id = protoBuf.readString(); - } - }, - { user_id: '', session_id: 0, quick_action_id: 0, device_id: '' }, - ); - - this?.log?.debug && this.log.debug('Talkback ended on "%s"', packet.device_id); + if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) { + let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.TalkbackEnd').decode(payload).toJSON(); + this?.log?.debug && this.log.debug('Talkback ended on "%s"', decodedMessage.device_id); + } } #handleNexusData(data) { // Process the rawdata from our socket connection and convert into nexus packets to take action against - this.packets = this.packets.length === 0 ? data : Buffer.concat([this.packets, data]); + this.#packets = this.#packets.length === 0 ? data : Buffer.concat([this.#packets, data]); - while (this.packets.length >= 3) { + while (this.#packets.length >= 3) { let headerSize = 3; - let packetType = this.packets.readUInt8(0); - let packetSize = this.packets.readUInt16BE(1); + let packetType = this.#packets.readUInt8(0); + let packetSize = this.#packets.readUInt16BE(1); if (packetType === PacketType.LONG_PLAYBACK_PACKET) { headerSize = 5; - packetSize = this.packets.readUInt32BE(1); + packetSize = this.#packets.readUInt32BE(1); } - if (this.packets.length < headerSize + packetSize) { + if (this.#packets.length < headerSize + packetSize) { // We dont have enough data in the buffer yet to process the full packet // so, exit loop and await more data break; } - let protoBufPayload = new protoBuf(this.packets.slice(headerSize, headerSize + packetSize)); + let protoBufPayload = this.#packets.subarray(headerSize, headerSize + packetSize); + this.#packets = this.#packets.subarray(headerSize + packetSize); + switch (packetType) { case PacketType.PING: { break; @@ -681,8 +485,8 @@ export default class NexusTalk extends Streamer { case PacketType.OK: { // process any pending messages we have stored - this.authorised = true; // OK message, means we're connected and authorised to Nexus - for (let message = this.messages.shift(); message; message = this.messages.shift()) { + this.#authorised = true; // OK message, means we're connected and authorised to Nexus + for (let message = this.#messages.shift(); message; message = this.#messages.shift()) { this.#sendMessage(message.type, message.data); } @@ -733,9 +537,6 @@ export default class NexusTalk extends Streamer { break; } } - - // Remove the section of data we've just processed from our pending buffer - this.packets = this.packets.slice(headerSize + packetSize); } } } diff --git a/src/protect.js b/src/protect.js index 88a65bc..7cfaf97 100644 --- a/src/protect.js +++ b/src/protect.js @@ -1,7 +1,7 @@ // Nest Protect // Part of homebridge-nest-accfactory // -// Code version 30/8/2024 +// Code version 9/8/2024 // Mark Hulskamp 'use strict'; @@ -131,11 +131,11 @@ export default class NestProtect extends HomeKitDevice { ); if (deviceData.smoke_status !== 0 && this.deviceData.smoke_status === 0) { - this?.log?.warn && this.log.warn('Smoke detected in "%s"', this.deviceData.description); + this?.log?.warn && this.log.warn('Smoke detected in "%s"', deviceData.description); } if (deviceData.smoke_status === 0 && this.deviceData.smoke_status !== 0) { - this?.log?.info && this.log.info('Smoke is nolonger detected in "%s"', this.deviceData.description); + this?.log?.info && this.log.info('Smoke is nolonger detected in "%s"', deviceData.description); } // Update carbon monoxide details @@ -161,11 +161,11 @@ export default class NestProtect extends HomeKitDevice { ); if (deviceData.co_status !== 0 && this.deviceData.co_status === 0) { - this?.log?.warn && this.log.warn('Abnormal carbon monoxide levels detected in "%s"', this.deviceData.description); + this?.log?.warn && this.log.warn('Abnormal carbon monoxide levels detected in "%s"', deviceData.description); } if (deviceData.co_status === 0 && this.deviceData.co_status !== 0) { - this?.log?.info && this.log.info('Carbon monoxide levels have returned to normal in "%s"', this.deviceData.description); + this?.log?.info && this.log.info('Carbon monoxide levels have returned to normal in "%s"', deviceData.description); } // Update motion service if present diff --git a/src/protobuf/googlehome/foyer.proto b/src/protobuf/googlehome/foyer.proto index a43b654..4e58631 100644 --- a/src/protobuf/googlehome/foyer.proto +++ b/src/protobuf/googlehome/foyer.proto @@ -15,7 +15,6 @@ message StructuresService { string requestId = 3; // 64 hex characters - random is OK int32 unknown1 = 4; // always 1? - int32 unknown2 = 5; // always 1? repeated DefaultHiddenDeviceType deviceTypesToUnhideArray = 10; } @@ -28,15 +27,20 @@ message StructuresService { string uuid = 1; string name = 2; Address address = 3; - EmailAddress emailAddress = 4; + repeated EmailAddress linked_users = 4; repeated Room rooms = 6; repeated Device devices = 7; } message Address { string line1 = 1; - string line2 = 2; + message Coordinates { + double latitude = 1; + double longitude = 2; + } + Coordinates coordinates = 2; uint64 timeCreated = 5; + string timezone = 6; } message EmailAddress { @@ -47,7 +51,10 @@ message StructuresService { message Room { string uuid = 1; string name = 3; - google.protobuf.StringValue type = 4; + message Category { + string name = 1; + } + Category category = 4; repeated Device devices = 5; } @@ -162,8 +169,6 @@ message CameraService { } } -// let request = { device: { googleDeviceId: x, service: { value: 'nest-home-assistant-prod' } }, trait: { category: 'cameraStream', updateTrait: { name: 'cameraStreamLiveViewImage', value: { intValue: 1 } } } } - message HomeControlService { message UpdateTraitsRequest { message Request { diff --git a/src/protobuf/nest/messages.proto b/src/protobuf/nest/messages.proto index 5025f8a..dd15b84 100644 --- a/src/protobuf/nest/messages.proto +++ b/src/protobuf/nest/messages.proto @@ -1,7 +1,29 @@ syntax = "proto3"; +import "../nestlabs/gateway/v2.proto"; + package nest.messages; +message IncomingMessage { + repeated nestlabs.gateway.v2.ResourceMeta resourceMetas = 1; + repeated nestlabs.gateway.v2.TraitState get = 3; +} + +message StreamBody { + repeated IncomingMessage message = 1; + google.rpc.Status status = 2; + repeated bytes noop = 15; +} + +message TraitSetRequest { + message TraitObject { + nestlabs.gateway.v2.TraitId traitId = 1; + google.protobuf.Any property = 2; + } + + repeated TraitObject set = 1; +} + message SchemaVersion { uint32 currentVersion = 1; uint32 minCompatVersion = 2; diff --git a/src/protobuf/nest/nexustalk.proto b/src/protobuf/nest/nexustalk.proto new file mode 100644 index 0000000..dd405d9 --- /dev/null +++ b/src/protobuf/nest/nexustalk.proto @@ -0,0 +1,181 @@ +syntax = "proto3"; + +package nest.nexustalk.v1; + +enum Profile { + AVPROFILE_MOBILE_1 = 1; + AVPROFILE_HD_MAIN_1 = 2; + AUDIO_AAC = 3; + AUDIO_SPEEX = 4; + AUDIO_OPUS = 5; + VIDEO_H264_50KBIT_L12 = 6; + VIDEO_H264_530KBIT_L31 = 7; + VIDEO_H264_100KBIT_L30 = 8; + VIDEO_H264_2MBIT_L40 = 9; + VIDEO_H264_50KBIT_L12_THUMBNAIL = 10; + META = 11; + DIRECTORS_CUT = 12; + AUDIO_OPUS_LIVE = 13; + VIDEO_H264_L31 = 14; + VIDEO_H264_L40 = 15; +} + +message Hello { + enum ProtocolVersion { + VERSION_1 = 1; + VERSION_2 = 2; + VERSION_3 = 3; + } + + enum ClientType { + ANDROID = 1; + IOS = 2; + WEB = 3; + } + + ProtocolVersion protocolVersion = 1; + string uuid = 2; + bool requireConnectedCamera = 3; + string sessionToken = 4; + bool isCamera = 5; + string deviceId = 6; + string userAgent = 7; + string serviceAccessKey = 8; + ClientType clientType = 9; + string wwnAccessToken = 10; + string encryptedDeviceId = 11; + bytes authoriseRequest = 12; + string clientIpAddress = 13; + bool requireOwnerServer = 15; +} + +message AuthoriseRequest { + string sessionToken = 1; + string wwnAccessToken = 2; + string serviceAccessKey = 3; + string oliveToken = 4; +} + +message Redirect { + string newHost = 1; + bool isTranscode = 2; +} + +message Ok { + uint32 udpPort = 1; +} + +message PlaybackPacket { + message DirectorsCutRegions { + uint32 id = 1; + uint32 left = 2; + uint32 right = 3; + uint32 top = 4; + uint32 bottom = 5; + } + uint32 sessionId = 1; + uint32 channelId = 2; + sint32 timestampDelta = 3; + bytes payload = 4; + uint64 latencyRtpSequence = 5; + uint64 latencyRtpSsrc = 6; + repeated DirectorsCutRegions directorsCutRegions = 7; +} + +message PlaybackEnd { + enum Reason { + USER_ENDED_SESSION = 0; + ERROR_TIME_NOT_AVAILABLE = 1; + ERROR_PROFILE_NOT_AVAILABLE = 2; + ERROR_TRANSCODE_NOT_AVAILABLE = 3; + ERROR_LEAF_NODE_CANNOT_REACH_CAMERA = 4; + PLAY_END_SESSION_COMPLETE = 128; + } + + uint32 sessionId = 1; + Reason reason = 2; +} + +message PlaybackBegin { + enum CodecType { + SPEEX = 0; + PCM_S16_LE = 1; + H264 = 2; + AAC = 3; + OPUS = 4; + META = 5; + DIRECTORS_CUT = 6; + } + + message Channels { + uint32 channelId = 1; + CodecType codecType = 2; + uint32 sampleRate = 3; + bytes privateData = 4; + double startTime = 5; + double rtpStartTime = 6; + uint32 udpSsrc = 7; + Profile profile = 8; + } + uint32 sessionId = 1; + repeated Channels channels = 2; + bytes srtpMasterKey = 3; + bytes srtpMasterSalt = 4; + uint32 fecKVal = 5; + uint32 fecNVal = 7; +} + +message Error { + enum ErrorCode { + ERROR_CAMERA_NOT_CONNECTED = 1; + ERROR_ILLEGAL_PACKET = 2; + ERROR_AUTHORIZATION_FAILED = 3; + ERROR_NO_TRANSCODER_AVAILABLE = 4; + ERROR_TRANSCODE_PROXY_ERROR = 5; + ERROR_INTERNAL = 6; + } + + ErrorCode code = 1; + string message = 2; +} + +message TalkbackBegin { + string userId = 1; + uint32 sessionId = 2; + uint32 quickActionId = 3; + string deviceId = 4; +} + +message TalkbackEnd { + string userId = 1; + uint32 sessionId = 2; + uint32 quickActionId = 3; + string deviceId = 4; +} + +message StartPlayback { + enum ProfileNotFoundAction { + REDIRECT = 0; + USE_NEXT_AVAILABLE = 1; + } + + uint32 sessionId = 1; + Profile profile = 2; + double startTime = 3; + bytes externalIp = 4; + uint32 externalPort = 5; + repeated Profile otherProfiles = 6; + ProfileNotFoundAction profileNotFoundAction = 7; +} + +message StopPlayback { + uint32 sessionId = 1; +} + +message AudioPayload { + bytes payload = 1; + uint32 sessionId = 2; + CodecType codec = 3; + uint32 sampleRate = 4; + uint32 latencyMeasureTag = 5; +} diff --git a/src/protobuf/root.proto b/src/protobuf/root.proto index 6d45406..a73de12 100644 --- a/src/protobuf/root.proto +++ b/src/protobuf/root.proto @@ -1,9 +1,7 @@ -// Protobuf schema for Nest Observe and Nest BatchUpdateState API calls - syntax = "proto3"; -import "google/trait/product/camera.proto"; import "nest/messages.proto"; +import "google/trait/product/camera.proto"; import "nest/trait/audio.proto"; import "nest/trait/detector.proto"; import "nest/trait/hvac.proto"; @@ -38,27 +36,4 @@ import "weave/trait/schedule.proto"; import "weave/trait/security.proto"; import "weave/trait/time.proto"; import "nestlabs/gateway/v2.proto"; -import "nestlabs/history/v1.proto"; -import "googlehome/foyer.proto"; - -package nest.rpc; - -message NestTraitSetRequest { - message TraitObject { - nestlabs.gateway.v2.TraitId traitId = 1; - google.protobuf.Any property = 2; - } - - repeated TraitObject set = 1; -} - -message NestIncomingMessage { - repeated nestlabs.gateway.v2.ResourceMeta resourceMetas = 1; - repeated nestlabs.gateway.v2.TraitState get = 3; -} - -message StreamBody { - repeated NestIncomingMessage message = 1; - google.rpc.Status status = 2; - repeated bytes noop = 15; -} \ No newline at end of file +import "nestlabs/history/v1.proto"; \ No newline at end of file diff --git a/src/streamer.js b/src/streamer.js index 70f0004..0cf0f67 100644 --- a/src/streamer.js +++ b/src/streamer.js @@ -13,7 +13,7 @@ // streamer.talkingAudio(talkingData) // streamer.update(deviceData) <- call super after // -// Code version 6/9/2024 +// Code version 11/9/2024 // Mark Hulskamp 'use strict'; @@ -35,17 +35,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a define // Streamer object export default class Streamer { - cameraOfflineFrame = undefined; - cameraVideoOffFrame = undefined; - videoEnabled = undefined; - audioEnabled = undefined; - online = undefined; + videoEnabled = undefined; // Video stream on camera enabled or not + audioEnabled = undefined; // Audio from camera enabled or not + online = undefined; // Camera online or not host = ''; // Host to connect to or connected too - socket = null; // TCP socket object + uuid = undefined; // UUID of the device connecting + connected = false; // Streamer connected to endpoint // Internal data only for this class #outputTimer = undefined; // Timer for non-blocking loop to stream output data #outputs = {}; // Output streams ie: buffer, live, record + #cameraOfflineFrame = undefined; // Camera offline video frame + #cameraVideoOffFrame = undefined; // Video turned off on camera video frame constructor(deviceData, options) { // Setup logger object if passed as option @@ -63,6 +64,7 @@ export default class Streamer { this.online = deviceData?.online === true; this.videoEnabled = deviceData?.streaming_enabled === true; this.audioEnabled = deviceData?.audio_enabled === true; + this.uuid = deviceData?.uuid; // Setup location for *.h264 frame files. This can be overriden by a passed in option let resourcePath = path.resolve(__dirname + '/res'); // Default location for *.h264 files @@ -76,19 +78,19 @@ export default class Streamer { // load buffer for camera offline image in .h264 frame if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)) === true) { - this.cameraOfflineFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)); + this.#cameraOfflineFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)); // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router - if (this.cameraOfflineFrame.indexOf(H264NALStartcode) === 0) { - this.cameraOfflineFrame = this.cameraOfflineFrame.subarray(H264NALStartcode.length); + if (this.#cameraOfflineFrame.indexOf(H264NALStartcode) === 0) { + this.#cameraOfflineFrame = this.#cameraOfflineFrame.subarray(H264NALStartcode.length); } } // load buffer for camera stream off image in .h264 frame if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)) === true) { - this.cameraVideoOffFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)); + this.#cameraVideoOffFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)); // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router - if (this.cameraVideoOffFrame.indexOf(H264NALStartcode) === 0) { - this.cameraVideoOffFrame = this.cameraVideoOffFrame.subarray(H264NALStartcode.length); + if (this.#cameraVideoOffFrame.indexOf(H264NALStartcode) === 0) { + this.#cameraVideoOffFrame = this.#cameraVideoOffFrame.subarray(H264NALStartcode.length); } } @@ -103,15 +105,15 @@ export default class Streamer { Object.values(this.#outputs).forEach((output) => { // Monitor for camera going offline and/or video enabled/disabled // We'll insert the appropriate video frame into the stream - if (this.online === false && this.cameraOfflineFrame !== undefined && outputVideoFrame === true) { + if (this.online === false && this.#cameraOfflineFrame !== undefined && outputVideoFrame === true) { // Camera is offline so feed in our custom h264 frame and AAC silence - output.buffer.push({ type: 'video', time: dateNow, data: this.cameraOfflineFrame }); + output.buffer.push({ type: 'video', time: dateNow, data: this.#cameraOfflineFrame }); output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); lastTimeVideo = dateNow; } - if (this.online === true && this.videoEnabled === false && this.cameraVideoOffFrame !== undefined && outputVideoFrame === true) { + if (this.online === true && this.videoEnabled === false && this.#cameraVideoOffFrame !== undefined && outputVideoFrame === true) { // Camera video is turned off so feed in our custom h264 frame and AAC silence - output.buffer.push({ type: 'video', time: dateNow, data: this.cameraVideoOffFrame }); + output.buffer.push({ type: 'video', time: dateNow, data: this.#cameraVideoOffFrame }); output.buffer.push({ type: 'audio', time: dateNow, data: AACAudioSilence }); lastTimeVideo = dateNow; } @@ -150,10 +152,10 @@ export default class Streamer { startBuffering() { if (this.#outputs?.buffer === undefined) { // No active buffer session, start connection to streamer - if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + if (this.connected === false && typeof this.host === 'string' && this.host !== '') { if (typeof this.connect === 'function') { - this.connect(this.host); this?.log?.debug && this.log.debug('Started buffering from "%s"', this.host); + this.connect(this.host); } } @@ -199,7 +201,7 @@ export default class Streamer { }); } - if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + if (this.connected === false && typeof this.host === 'string' && this.host !== '') { // We do not have an active socket connection, so startup connection to host if (typeof this.connect === 'function') { this.connect(this.host); @@ -218,9 +220,9 @@ export default class Streamer { // finally, we've started live stream this?.log?.debug && this.log.debug( - 'Started live stream from "%s" %s and sesssion id of "%s"', + 'Started live stream from "%s" %s "%s"', this.host, - talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio' : '', + talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio and sesssion id of' : 'and sesssion id of', sessionID, ); } @@ -239,7 +241,7 @@ export default class Streamer { }); } - if (this.socket === null && typeof this.host === 'string' && this.host !== '') { + if (this.connected === false && typeof this.host === 'string' && this.host !== '') { // We do not have an active socket connection, so startup connection to host if (typeof this.connect === 'function') { this.connect(this.host); @@ -302,20 +304,21 @@ export default class Streamer { return; } - this.online = deviceData?.online === true; - this.videoEnabled = deviceData?.streaming_enabled === true; - this.audioEnabled = deviceData?.audio_enabled === true; - if (this.host !== deviceData.streaming_host) { this.host = deviceData.streaming_host; - this?.log?.debug && this.log.debug('New streaming host has requested a new host "%s" for connection', this.host); + this?.log?.debug && this.log.debug('New streaming host has been requested for connection. Host requested is "%s"', this.host); } - if (this.online !== deviceData.online || this.videoEnabled !== deviceData.streaming_enabled) { + if ( + this.online !== deviceData.online || + this.videoEnabled !== deviceData.streaming_enabled || + this.audioEnabled !== deviceData?.audio_enabled + ) { // Online status or streaming status has changed has changed this.online = deviceData?.online === true; this.videoEnabled = deviceData?.streaming_enabled === true; - if ((this.online === false || this.videoEnabled === false) && typeof this.close === 'function') { + this.audioEnabled = deviceData?.audio_enabled === true; + if ((this.online === false || this.videoEnabled === false || this.audioEnabled === false) && typeof this.close === 'function') { this.close(true); // as offline or streaming not enabled, close socket } if (this.online === true && this.videoEnabled === true && typeof this.connect === 'function') { diff --git a/src/system.js b/src/system.js index 206a995..6e02171 100644 --- a/src/system.js +++ b/src/system.js @@ -1,12 +1,11 @@ // Nest System communications // Part of homebridge-nest-accfactory // -// Code version 7/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; // Define external module requirements -import axios from 'axios'; import protobuf from 'protobufjs'; // Define nodejs module requirements @@ -24,6 +23,7 @@ import { fileURLToPath } from 'node:url'; 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'; @@ -47,6 +47,7 @@ export default class NestAccfactory { SMOKESENSOR: 'protect', CAMERA: 'camera', DOORBELL: 'doorbell', + FLOODLIGHT: 'floodlight', WEATHER: 'weather', LOCK: 'lock', // yet to implement ALARM: 'alarm', // yet to implement @@ -59,13 +60,11 @@ export default class NestAccfactory { static GoogleConnection = 'google'; // Google account connection static NestConnection = 'nest'; // Nest account connection - static SDMConnection = 'sdm'; // NOT coded, but here for future reference - static HomeFoyerConnection = 'foyer'; // Google Home foyer connection cachedAccessories = []; // Track restored cached accessories // Internal data only for this class - #connections = {}; // Array of confirmed connections, indexed by type + #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 @@ -75,18 +74,48 @@ export default class NestAccfactory { this.api = api; // Perform validation on the configuration passed into us and set defaults if not present - if (typeof this.config?.nest !== 'object') { - this.config.nest = {}; - } - this.config.nest.access_token = typeof this.config.nest?.access_token === 'string' ? this.config.nest.access_token : ''; - this.config.nest.fieldTest = typeof this.config.nest?.fieldTest === 'boolean' ? this.config.nest.fieldTest : false; - if (typeof this.config?.google !== 'object') { - this.config.google = {}; + // Build our accounts connection object. Allows us to have multiple diffent accont 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.NestConnection, + 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.GoogleConnection, + authorised: false, + issuetoken: this.config[key].issuetoken, + cookie: this.config[key].cookie, + fieldTest: typeof this.config[key]?.fieldTest === 'boolean' ? this.config[key].fieldTest : false, + 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; } - this.config.google.issuetoken = typeof this.config.google?.issuetoken === 'string' ? this.config.google.issuetoken : ''; - this.config.google.cookie = typeof this.config.google?.cookie === 'string' ? this.config.google.cookie : ''; - this.config.google.fieldTest = typeof this.config.google?.fieldTest === 'boolean' ? this.config.google.fieldTest : false; if (typeof this.config?.options !== 'object') { this.config.options = {}; @@ -183,12 +212,6 @@ export default class NestAccfactory { } } - // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back. - if (this.config.nest.access_token === '' && (this.config.google.issuetoken === '' || this.config.google.cookie === '')) { - this?.log?.error && this.log.error('JSON plugin configuration is invalid. Please review'); - return; - } - 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 @@ -212,249 +235,188 @@ export default class NestAccfactory { } async discoverDevices() { - await this.#connect(); - if (this.#connections?.nest !== undefined) { - // We have a 'Nest' connected account, so process accordingly - this.#subscribeREST(NestAccfactory.NestConnection, false); - this.#subscribeProtobuf(NestAccfactory.NestConnection); - } - - if (this.#connections?.google !== undefined) { - // We have a 'Google' connected account, so process accordingly - this.#subscribeREST(NestAccfactory.GoogleConnection, false); - this.#subscribeProtobuf(NestAccfactory.GoogleConnection); - } - // Setup event listeners for set/get calls from devices this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => this.#set(deviceUUID, values)); this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => this.#get(deviceUUID, values)); + + Object.keys(this.#connections).forEach((uuid) => { + this.#connect(uuid).then(() => { + if (this.#connections[uuid].authorised === true) { + this.#subscribeREST(uuid, true); + this.#subscribeProtobuf(uuid); + } + }); + }); } - async #connect() { - if ( - typeof this.config?.google === 'object' && - typeof this.config?.google?.issuetoken === 'string' && - this.config?.google?.issuetoken !== '' && - typeof this.config?.google?.cookie === 'string' && - this.config?.google?.cookie !== '' - ) { - let referer = 'home.nest.com'; // Which host is 'actually' doing the request - let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API - let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API - let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API - - if (this.config?.google.fieldTest === true) { - // FieldTest mode support enabled in configuration, so update default endpoints - // This is all 'untested' - this?.log?.info && this.log.info('Using FieldTest API endpoints for Google account'); - - referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request - restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API - cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API - protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API - } + async #connect(connectionUUID) { + if (typeof this.#connections?.[connectionUUID] === 'object') { + this.#connections[connectionUUID].authorised === false; // Mark connection as no-longer authorised + if (this.#connections[connectionUUID].type === NestAccfactory.GoogleConnection) { + // 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].protobufRoot = null; + this.#connections[connectionUUID].token = googleToken; + this.#connections[connectionUUID].cameraAPI = { + key: 'Authorization', + value: 'Basic ', + token: googleToken, + oauth2: googleOAuth2Token, + }; - // 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'); + // Set timeout for token expiry refresh + this.#connections[connectionUUID].timer = clearInterval(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 - let request = { - method: 'get', - url: this.config.google.issuetoken, - headers: { - referer: 'https://accounts.google.com/o/oauth2/iframe', - 'User-Agent': USERAGENT, - cookie: this.config.google.cookie, - 'Sec-Fetch-Mode': 'cors', - 'X-Requested-With': 'XmlHttpRequest', - }, - }; - await axios(request) - .then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Google API Authorisation failed with error'); - } - this.special = response.data.access_token; + 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?.log?.error && this.log.error('Authorisation failed using Google account'); + }); + } - let request = { - method: 'post', - url: 'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt', + if (this.#connections[connectionUUID].type === NestAccfactory.NestConnection) { + // 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://' + referer, + referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, - Authorization: 'Bearer ' + response.data.access_token, + 'Content-Type': 'application/x-www-form-urlencoded', }, - data: - 'embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=' + - response.data.access_token + - '&policy_id=authproxy-oauth-policy', - }; - - await axios(request).then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Google Camera API Token get failed with error'); + }, + 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 googleToken = response.data.jwt; - let tokenExpire = Math.floor(new Date(response.data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr + let nestToken = data.items[0].session_token; - let request = { - method: 'get', - url: 'https://' + restAPIHost + '/session', + await fetchWrapper('get', 'https://' + this.#connections[connectionUUID].restAPIHost + '/session', { headers: { - referer: 'https://' + referer, + referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, - Authorization: 'Basic ' + googleToken, + Authorization: 'Basic ' + this.#connections[connectionUUID].access_token, }, - }; - - await axios(request).then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Session API get failed with error'); - } + }) + .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].protobufRoot = null; + 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, + }; - this?.log?.success && this.log.success('Successfully authorised using Google account'); - - // Store successful connection details - this.#connections['google'] = { - type: 'google', - referer: referer, - restAPIHost: restAPIHost, - cameraAPIHost: cameraAPIHost, - protobufAPIHost: protobufAPIHost, - userID: response.data.userid, - transport_url: response.data.urls.transport_url, - weather_url: response.data.urls.weather_url, - timer: null, - protobufRoot: null, - token: googleToken, - cameraAPI: { - key: 'Authorization', - value: 'Basic ', - token: googleToken, - }, - }; - - // Set timeout for token expiry refresh - clearInterval(this.#connections['google'].timer); - this.#connections['google'].timer = setTimeout( - () => { - this?.log?.info && this.log.info('Performing periodic token refresh for Google account'); - this.#connect(); - }, - (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000, - ); // Refresh just before token expiry - }); + // Set timeout for token expiry refresh + this.#connections[connectionUUID].timer = clearInterval(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?.log?.error && this.log.error('Authorisation failed 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?.log?.error && this.log.error('Authorisation failed using Google account'); - }); - } - - if (typeof this.config?.nest?.access_token === 'string' && this.config?.nest?.access_token !== '') { - let referer = 'home.nest.com'; // Which host is 'actually' doing the request - let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API - let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API - let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API - - if (this.config?.nest.fieldTest === true) { - // FieldTest mode support enabled in configuration, so update default endpoints - // This is all 'untested' - this?.log?.info && this.log.info('Using FieldTest API endpoints for Nest account'); - - referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request - restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API - cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API - protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API } - - // 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'); - - let request = { - method: 'post', - url: 'https://webapi.' + cameraAPIHost + '/api/v1/login.login_nest', - withCredentials: true, - headers: { - referer: 'https://' + referer, - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USERAGENT, - }, - data: Buffer.from('access_token=' + this.config.nest.access_token, 'utf8'), - }; - await axios(request) - .then(async (response) => { - if ( - typeof response.status !== 'number' || - response.status !== 200 || - typeof response.data.status !== 'number' || - response.data.status !== 0 - ) { - throw new Error('Nest API Authorisation failed with error'); - } - - let nestToken = response.data.items[0].session_token; - - let request = { - method: 'get', - url: 'https://' + restAPIHost + '/session', - headers: { - referer: 'https://' + referer, - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.config.nest.access_token, - }, - }; - - await axios(request).then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('Nest Session API get failed with error'); - } - - this?.log?.success && this.log.success('Successfully authorised using Nest account'); - - // Store successful connection details - this.#connections['nest'] = { - type: 'nest', - referer: referer, - restAPIHost: restAPIHost, - cameraAPIHost: cameraAPIHost, - protobufAPIHost: protobufAPIHost, - userID: response.data.userid, - transport_url: response.data.urls.transport_url, - weather_url: response.data.urls.weather_url, - timer: null, - protobufRoot: null, - token: this.config.nest.access_token, - cameraAPI: { - key: 'cookie', - value: this.config.fieldTest === true ? 'website_ft=' : 'website_2=', - token: nestToken, - }, - }; - - // Set timeout for token expiry refresh - clearInterval(this.#connections['nest'].timer); - this.#connections['nest'].timer = setTimeout( - () => { - this?.log?.info && this.log.info('Performing periodic token refresh for Nest account'); - this.#connect(); - }, - 1000 * 3600 * 24, - ); // Refresh token every 24hrs - }); - }) - // eslint-disable-next-line no-unused-vars - .catch((error) => { - // The token we used to obtained a Nest session failed, so overall authorisation failed - this?.log?.error && this.log.error('Authorisation failed using Nest account'); - }); } } - async #subscribeREST(connectionType, fullRefresh) { + 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', @@ -479,64 +441,56 @@ export default class NestAccfactory { quartz: ['where_id', 'structure_id', 'nexus_api_http_server_url'], }; - let restAPIURL = ''; - let restAPIJSONData = {}; - if (Object.keys(this.#rawData).length === 0 || (typeof fullRefresh === 'boolean' && fullRefresh === true)) { - // Setup for a full data read from REST API - restAPIURL = - 'https://' + - this.#connections[connectionType].restAPIHost + - '/api/0.1/user/' + - this.#connections[connectionType].userID + - '/app_launch'; - restAPIJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] }; - } - if (Object.keys(this.#rawData).length !== 0 && typeof fullRefresh === 'boolean' && fullRefresh === false) { - // Setup to subscribe to object changes we know about from REST API - restAPIURL = this.#connections[connectionType].transport_url + '/v6/subscribe'; - restAPIJSONData = { objects: [] }; - - Object.entries(this.#rawData).forEach(([object_key]) => { - if ( - this.#rawData[object_key]?.source === NestAccfactory.DataSource.REST && - this.#rawData[object_key]?.connection === connectionType && - typeof this.#rawData[object_key]?.object_revision === 'number' && - typeof this.#rawData[object_key]?.object_timestamp === 'number' - ) { - restAPIJSONData.objects.push({ + // 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 ths REST API, so setup read using known objects + 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: this.#rawData[object_key].object_revision, - object_timestamp: this.#rawData[object_key].object_timestamp, + object_revision: object.object_revision, + object_timestamp: object.object_timestamp, }); - } - }); + }); } - let request = { - method: 'post', - url: restAPIURL, - responseType: 'json', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[connectionType].token, + fetchWrapper( + 'post', + subscribeURL, + { + headers: { + referer: 'https://' + this.#connections[connectionUUID].referer, + 'User-Agent': USERAGENT, + Authorization: 'Basic ' + this.#connections[connectionUUID].token, + }, + keepalive: true, + timeout: 300 * 60, }, - data: JSON.stringify(restAPIJSONData), - }; - axios(request) - .then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API subscription failed with error'); - } - - let data = {}; + JSON.stringify(subscribeJSONData), + ) + .then((response) => response.json()) + .then(async (data) => { let deviceChanges = []; // No REST API devices changes to start with - if (typeof response.data?.updated_buckets === 'object') { + if (typeof data?.updated_buckets === 'object') { // This response is full data read - data = response.data.updated_buckets; + data = data.updated_buckets; } - if (typeof response.data?.objects === 'object') { + if (typeof data?.objects === 'object') { // This response contains subscribed data updates - data = response.data.objects; + data = data.objects; } // Process the data we received @@ -555,7 +509,7 @@ export default class NestAccfactory { value.value.weather = this.#rawData[value.object_key].value.weather; } value.value.weather = await this.#getWeatherData( - connectionType, + connectionUUID, value.object_key, value.value.latitude, value.value.longitude, @@ -580,34 +534,30 @@ export default class NestAccfactory { ? this.#rawData[value.object_key].value.properties : []; - let request = { - method: 'get', - url: - 'https://webapi.' + - this.#connections[connectionType].cameraAPIHost + + 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[connectionType].referer, - 'User-Agent': USERAGENT, - [this.#connections[connectionType].cameraAPI.key]: - this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token, + { + 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, }, - responseType: 'json', - timeout: NESTAPITIMEOUT, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API had error retrieving camera/doorbell details'); - } - - value.value.properties = response.data.items[0].properties; + ) + .then((response) => response.json()) + .then((data) => { + value.value.properties = data.items[0].properties; }) .catch((error) => { - this?.log?.debug && - this?.log?.debug && - this.log.debug('REST API had error retrieving camera/doorbell details. Error was "%s"', error?.code); + if (error?.name !== 'TimeoutError' && 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 = @@ -615,26 +565,19 @@ export default class NestAccfactory { ? this.#rawData[value.object_key].value.activity_zones : []; - request = { - method: 'get', - url: value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], + await fetchWrapper('get', value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], { headers: { - referer: 'https://' + this.#connections[connectionType].referer, + referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, - [this.#connections[connectionType].cameraAPI.key]: - this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token, + [this.#connections[connectionUUID].cameraAPI.key]: + this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token, }, - responseType: 'json', timeout: NESTAPITIMEOUT, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API had error retrieving camera/doorbell activity zones'); - } - + }) + .then((response) => response.json()) + .then((data) => { let zones = []; - response.data.forEach((zone) => { + data.forEach((zone) => { if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') { zones.push({ id: zone.id === 0 ? 1 : zone.id, @@ -648,9 +591,12 @@ export default class NestAccfactory { value.value.activity_zones = zones; }) .catch((error) => { - this?.log?.debug && - this?.log?.debug && - this.log.debug('REST API had error retrieving camera/doorbell activity zones. Error was "%s"', error?.code); + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug( + 'REST API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"', + error?.code, + ); + } }); } @@ -687,7 +633,7 @@ export default class NestAccfactory { 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 = connectionType; + this.#rawData[value.object_key].connection = connectionUUID; this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST; this.#rawData[value.object_key].timers = {}; // No timers running for this object this.#rawData[value.object_key].value = {}; @@ -716,16 +662,21 @@ export default class NestAccfactory { await this.#processPostSubscribe(deviceChanges); }) .catch((error) => { - if (error?.code !== 'ECONNRESET') { - this?.log?.error && this.log.error('REST API subscription failed with error "%s"', error?.code); + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug('REST API had an error performing subscription. Will restart subscription'); } }) .finally(() => { - setTimeout(this.#subscribeREST.bind(this, connectionType, fullRefresh), 1000); + setTimeout(this.#subscribeREST.bind(this, connectionUUID, fullRefresh), 1000); }); } - async #subscribeProtobuf(connectionType) { + async #subscribeProtobuf(connectionUUID) { + 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 @@ -761,177 +712,184 @@ export default class NestAccfactory { } }; - let observeTraits = null; - if (fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true) { + // Attempt to load in protobuf files if not already done so for this connection + if ( + this.#connections[connectionUUID].protobufRoot === null && + fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true + ) { protobuf.util.Long = null; protobuf.configure(); - this.#connections[connectionType].protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto')); - if (this.#connections[connectionType].protobufRoot !== null) { - // Loaded in the protobuf files, so now dynamically build the 'observe' post body data based on what we have loaded - let observeTraitsList = []; - let traitTypeObserveParam = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams'); - let observeRequest = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest'); - if (traitTypeObserveParam !== null && observeRequest !== null) { - traverseTypes(this.#connections[connectionType].protobufRoot, (type) => { - // We only want to have certain trait main '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 ( - (connectionType === NestAccfactory.NestConnection && - 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)) || - (connectionType === NestAccfactory.GoogleConnection && - (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, '') })); - } - }); - observeTraits = observeRequest.encode(observeRequest.create({ stateTypes: [1, 2], traitTypeParams: observeTraitsList })).finish(); - } - } + this.#connections[connectionUUID].protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto')); } - let request = { - method: 'post', - url: 'https://' + this.#connections[connectionType].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[connectionType].token, - 'Content-Type': 'application/x-protobuf', - 'X-Accept-Content-Transfer-Encoding': 'binary', - 'X-Accept-Response-Streaming': 'true', - }, - responseType: 'stream', - data: observeTraits, - }; - axios(request) - .then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('protobuf API had error perform trait observe'); - } - - let deviceChanges = []; // No protobuf API devices changes to start with - let buffer = Buffer.alloc(0); - for await (const chunk of response.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.#connections[connectionType].protobufRoot - .lookup('nest.rpc.StreamBody') - .decode(buffer.subarray(0, messageSize)) - .toJSON(); - if (typeof decodedMessage?.message !== 'object') { - decodedMessage.message = []; - } - if (typeof decodedMessage?.message[0]?.get !== 'object') { - decodedMessage.message[0].get = []; - } - if (typeof decodedMessage?.message[0]?.resourceMetas !== 'object') { - decodedMessage.message[0].resourceMetas = []; - } + if (this.#connections[connectionUUID].protobufRoot !== null) { + // Loaded in the Protobuf files, so now dynamically build the 'observe' post body data based on what we have loaded + let observeTraitsList = []; + let observeBody = Buffer.alloc(0); + let traitTypeObserveParam = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams'); + let observeRequest = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest'); + if (traitTypeObserveParam !== null && observeRequest !== null) { + traverseTypes(this.#connections[connectionUUID].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.NestConnection && + 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.GoogleConnection && + (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(); + } - // 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 - let notAcceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === false); - let acceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true); - let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel); - decodedMessage.message[0].get = - ((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 - decodedMessage.message[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/ device - deviceChanges.push({ object_key: resource.resourceId, change: 'removed' }); + 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: 300 * 60, + }, + observeBody, + ) + .then((response) => response.body) + .then(async (data) => { + let deviceChanges = []; // No Protobuf API devices changes to start with + 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.#connections[connectionUUID].protobufRoot + .lookup('nest.messages.StreamBody') + .decode(buffer.subarray(0, messageSize)) + .toJSON(); + if (typeof decodedMessage?.message !== 'object') { + decodedMessage.message = []; + } + if (typeof decodedMessage?.message[0]?.get !== 'object') { + decodedMessage.message[0].get = []; + } + if (typeof decodedMessage?.message[0]?.resourceMetas !== 'object') { + decodedMessage.message[0].resourceMetas = []; } - }); - // 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?.message[0]?.get === 'object') { - await Promise.all( - decodedMessage.message[0].get.map(async (trait) => { - if (trait.traitId.traitLabel === 'configuration_done') { - if ( - this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true && - trait.patch.values?.deviceReady === true - ) { - deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); - } + // 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 + let notAcceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === false); + let acceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true); + let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel); + decodedMessage.message[0].get = + ((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 + decodedMessage.message[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/ device + deviceChanges.push({ object_key: resource.resourceId, change: 'removed' }); } - if (trait.traitId.traitLabel === 'camera_migration_status') { - // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home - if ( - this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !== 'MIGRATED_TO_GOOGLE_HOME' && - trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' && - this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' && - trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE' - ) { - deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); + }); + // 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?.message[0]?.get === 'object') { + await Promise.all( + decodedMessage.message[0].get.map(async (trait) => { + if (trait.traitId.traitLabel === 'configuration_done') { + if ( + this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true && + trait.patch.values?.deviceReady === true + ) { + deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); + } + } + if (trait.traitId.traitLabel === 'camera_migration_status') { + // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home + if ( + this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !== + 'MIGRATED_TO_GOOGLE_HOME' && + trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' && + this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' && + trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE' + ) { + deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' }); + } } - } - if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') { - this.#rawData[trait.traitId.resourceId] = {}; - this.#rawData[trait.traitId.resourceId].connection = connectionType; - this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF; - this.#rawData[trait.traitId.resourceId].timers = {}; // No timers running for this object - this.#rawData[trait.traitId.resourceId].value = {}; - } - this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] = - typeof trait.patch.values !== 'undefined' ? trait.patch.values : {}; + 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].timers = {}; // No timers running for this object + 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']; + // 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' && - typeof trait.patch.values?.geoCoordinate?.latitude === 'number' && - typeof trait.patch.values?.geoCoordinate?.longitude === 'number' - ) { - this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData( - connectionType, - trait.traitId.resourceId, - trait.patch.values.geoCoordinate.latitude, - trait.patch.values.geoCoordinate.longitude, - ); - } - }), - ); + // 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' && + typeof trait.patch.values?.geoCoordinate?.latitude === 'number' && + typeof trait.patch.values?.geoCoordinate?.longitude === 'number' + ) { + this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData( + connectionUUID, + trait.traitId.resourceId, + trait.patch.values.geoCoordinate.latitude, + trait.patch.values.geoCoordinate.longitude, + ); + } + }), + ); - await this.#processPostSubscribe(deviceChanges); - deviceChanges = []; // No more device changes now + await this.#processPostSubscribe(deviceChanges); + deviceChanges = []; // No more device changes now + } } } - } - }) - .catch((error) => { - if (error?.code !== 'ECONNRESET') { - this?.log?.error && this.log.error('protobuf API had error perform trait observe. Error was "%s"', error?.code); - } - }) - .finally(() => { - setTimeout(this.#subscribeProtobuf.bind(this, connectionType), 1000); - }); + }) + .catch((error) => { + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug('Protobuf API had an error performing trait observe. Will restart observe'); + } + }) + .finally(() => { + setTimeout(this.#subscribeProtobuf.bind(this, connectionUUID), 1000); + }); + } } async #processPostSubscribe(deviceChanges) { @@ -966,27 +924,37 @@ export default class NestAccfactory { // Device isn't marked as 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 + this?.log?.debug && + this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description); let tempDevice = new NestThermostat(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Thermostat', 9, true); } if (deviceData.device_type === NestAccfactory.DeviceType.TEMPSENSOR && typeof NestTemperatureSensor === 'function') { // Nest Temperature Sensor - Categories.SENSOR = 10 + this?.log?.debug && + this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description); let tempDevice = new NestTemperatureSensor(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Temperature Sensor', 10, true); } if (deviceData.device_type === NestAccfactory.DeviceType.SMOKESENSOR && typeof NestProtect === 'function') { // Nest Protect(s) - Categories.SENSOR = 10 + this?.log?.debug && + this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description); let tempDevice = new NestProtect(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Protect', 10, true); } if ( (deviceData.device_type === NestAccfactory.DeviceType.CAMERA || - deviceData.device_type === NestAccfactory.DeviceType.DOORBELL) && - (typeof NestCamera === 'function' || typeof NestDoorbell === 'function') + deviceData.device_type === NestAccfactory.DeviceType.DOORBELL || + deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) && + (typeof NestCamera === 'function' || typeof NestDoorbell === 'function' || typeof NestFloodlight === 'function') ) { + this?.log?.debug && + this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description); + let accessoryName = 'Nest ' + deviceData.model.replace(/\s*(?:\([^()]*\))/gi, ''); if (deviceData.device_type === NestAccfactory.DeviceType.CAMERA) { // Nest Camera(s) - Categories.IP_CAMERA = 17 @@ -998,39 +966,41 @@ export default class NestAccfactory { let tempDevice = new NestDoorbell(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add(accessoryName, 18, true); } + if (deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) { + // Nest Camera(s) with Floodlight - Categories.IP_CAMERA = 17 + let tempDevice = new NestFloodlight(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); + tempDevice.add(accessoryName, 17, true); + } // Setup polling loop for camera/doorbell zone data if not already created. - // This is only required for REST API data sources as these details are present in protobuf API + // This is only required for REST API data sources as these details are present in Protobuf API if ( - typeof this.#rawData[object.object_key]?.timers?.zones === 'undefined' && + this.#rawData?.[object.object_key] !== undefined && + this.#rawData[object.object_key]?.timers?.zones === undefined && this.#rawData[object.object_key].source === NestAccfactory.DataSource.REST ) { this.#rawData[object.object_key].timers.zones = setInterval(async () => { - if (typeof this.#rawData[object.object_key]?.value === 'object') { - let request = { - method: 'get', - url: - this.#rawData[object.object_key].value.nexus_api_http_server_url + + if (this.#rawData?.[object.object_key]?.value?.nexus_api_http_server_url !== undefined) { + await fetchWrapper( + 'get', + this.#rawData[object.object_key].value.nexus_api_http_server_url + '/cuepoint_category/' + object.object_key.split('.')[1], - headers: { - referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, - 'User-Agent': USERAGENT, - [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: - this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + - this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, + 'User-Agent': USERAGENT, + [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, + }, + timeout: CAMERAZONEPOLLING, }, - responseType: 'json', - timeout: CAMERAZONEPOLLING, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API had error retrieving camera/doorbell activity zones'); - } - + ) + .then((response) => response.json()) + .then((data) => { let zones = []; - response.data.forEach((zone) => { + data.forEach((zone) => { if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') { zones.push({ id: zone.id === 0 ? 1 : zone.id, @@ -1050,13 +1020,12 @@ export default class NestAccfactory { }) .catch((error) => { // Log debug message if wasn't a timeout - if (error?.code !== 'ECONNABORTED') { - this?.log?.debug && - this.log.debug( - 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"', - object.object_key, - error?.code, - ); + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug( + 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"', + object.object_key, + error?.code, + ); } }); } @@ -1064,7 +1033,7 @@ export default class NestAccfactory { } // Setup polling loop for camera/doorbell alert data if not already created - if (typeof this.#rawData[object.object_key]?.timers?.alerts === 'undefined') { + if (this.#rawData?.[object.object_key] !== undefined && this.#rawData?.[object.object_key]?.timers?.alerts === undefined) { this.#rawData[object.object_key].timers.alerts = setInterval(async () => { if ( typeof this.#rawData[object.object_key]?.value === 'object' && @@ -1115,7 +1084,7 @@ export default class NestAccfactory { // 'EVENT_UNFAMILIAR_FACE' = 'unfamiliar-face' // 'EVENT_PERSON_TALKING' = 'personHeard' // 'EVENT_DOG_BARKING' = 'dogBarking' - // <---- TODO (as the ones we use match from protobuf) + // <---- TODO (as the ones we use match from Protobuf) }, ); @@ -1140,31 +1109,27 @@ export default class NestAccfactory { this.#rawData[object.object_key]?.source === NestAccfactory.DataSource.REST ) { let alerts = []; // No alerts yet - let request = { - method: 'get', - url: - this.#rawData[object.object_key].value.nexus_api_http_server_url + + await fetchWrapper( + 'get', + this.#rawData[object.object_key].value.nexus_api_http_server_url + '/cuepoint/' + object.object_key.split('.')[1] + '/2?start_time=' + Math.floor(Date.now() / 1000 - 30), - headers: { - referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, - 'User-Agent': USERAGENT, - [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: - this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + - this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer, + 'User-Agent': USERAGENT, + [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]: + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value + + this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token, + }, + timeout: CAMERAALERTPOLLING, }, - responseType: 'json', - timeout: CAMERAALERTPOLLING, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API had error retrieving camera/doorbell activity notifications'); - } - - response.data.forEach((alert) => { + ) + .then((response) => response.json()) + .then((data) => { + data.forEach((alert) => { // Fix up alert zone IDs. If there is an ID of 0, we'll transform to 1. ie: main zone // If there are NO zone IDs, we'll put a 1 in there ie: main zone alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1)); @@ -1190,13 +1155,12 @@ export default class NestAccfactory { }) .catch((error) => { // Log debug message if wasn't a timeout - if (error?.code !== 'ECONNABORTED') { - this?.log?.debug && - this.log.debug( - 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"', - object.object_key, - error?.code, - ); + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug( + 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"', + object.object_key, + error?.code, + ); } }); @@ -1212,6 +1176,8 @@ export default class NestAccfactory { } if (deviceData.device_type === NestAccfactory.DeviceType.WEATHER && typeof NestWeather === 'function') { // Nest 'Virtual' weather station - Categories.SENSOR = 10 + this?.log?.debug && + this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description); let tempDevice = new NestWeather(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData); tempDevice.add('Nest Weather', 10, true); @@ -1251,12 +1217,12 @@ export default class NestAccfactory { let devices = {}; // Get the device(s) location from stucture - // We'll test in both REST and protobuf API data + // We'll test in both REST and Protobuf API data const get_location_name = (structure_id, where_id) => { let location = ''; // Check REST data - if (typeof this.#rawData['where.' + structure_id]?.value === 'object') { + if (typeof this.#rawData?.['where.' + structure_id]?.value === 'object') { this.#rawData['where.' + structure_id].value.wheres.forEach((value) => { if (where_id === value.where_id) { location = value.name; @@ -1264,7 +1230,7 @@ export default class NestAccfactory { }); } - // Check protobuf data + // Check Protobuf data if (typeof this.#rawData[structure_id]?.value?.located_annotations?.predefinedWheres === 'object') { Object.values(this.#rawData[structure_id].value.located_annotations.predefinedWheres).forEach((value) => { if (value.whereId.resourceId === where_id) { @@ -1358,7 +1324,7 @@ export default class NestAccfactory { .forEach(([object_key, value]) => { let tempDevice = {}; try { - if (value.source === NestAccfactory.DataSource.PROTOBUF) { + if (value?.source === NestAccfactory.DataSource.PROTOBUF) { let RESTTypeData = {}; RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64'); RESTTypeData.serial_number = value.value.device_identity.serialNumber; @@ -1398,50 +1364,49 @@ export default class NestAccfactory { ? true : false; RESTTypeData.can_cool = - value.value.hvac_equipment_capabilities.hasStage1Cool === true || - value.value.hvac_equipment_capabilities.hasStage2Cool === true || - value.value.hvac_equipment_capabilities.hasStage3Cool === true; + value.value?.hvac_equipment_capabilities?.hasStage1Cool === true || + value.value?.hvac_equipment_capabilities?.hasStage2Cool === true || + value.value?.hvac_equipment_capabilities?.hasStage3Cool === true; RESTTypeData.can_heat = - value.value.hvac_equipment_capabilities.hasStage1Heat === true || - value.value.hvac_equipment_capabilities.hasStage2Heat === true || - value.value.hvac_equipment_capabilities.hasStage3Heat === true; - RESTTypeData.temperature_lock = value.value.temperature_lock_settings.enabled === true; + value.value?.hvac_equipment_capabilities?.hasStage1Heat === true || + value.value?.hvac_equipment_capabilities?.hasStage2Heat === true || + value.value?.hvac_equipment_capabilities?.hasStage3Heat === true; + RESTTypeData.temperature_lock = value.value?.temperature_lock_settings?.enabled === true; RESTTypeData.temperature_lock_pin_hash = - typeof value.value.temperature_lock_settings.pinHash === 'string' && value.value.temperature_lock_settings.enabled === true - ? value.value.temperature_lock_settings.pinHash - : ''; - RESTTypeData.away = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_AWAY'; - RESTTypeData.occupancy = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_HOME'; + value.value?.temperature_lock_settings?.enabled === true ? value.value.temperature_lock_settings.pinHash : ''; + RESTTypeData.away = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY'; + RESTTypeData.occupancy = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_HOME'; //RESTTypeData.occupancy = (value.value.structure_mode.occupancy.activity === 'ACTIVITY_ACTIVE'); - RESTTypeData.vacation_mode = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_VACATION'; - RESTTypeData.description = typeof value.value.label?.label === 'string' ? value.value.label.label : ''; + RESTTypeData.vacation_mode = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION'; + RESTTypeData.description = value.value.label?.label !== undefined ? value.value.label.label : ''; RESTTypeData.location = get_location_name( - value.value.device_info.pairerId.resourceId, - value.value.device_located_settings.whereAnnotationRid.resourceId, + value.value?.device_info?.pairerId?.resourceId, + value.value?.device_located_settings?.whereAnnotationRid?.resourceId, ); // Work out current mode. ie: off, cool, heat, range and get temperature low/high and target RESTTypeData.hvac_mode = - value.value.target_temperature_settings.enabled.value === true + value.value?.target_temperature_settings?.enabled?.value === true && + value.value?.target_temperature_settings?.targetTemperature?.setpointType !== undefined ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() : 'off'; RESTTypeData.target_temperature_low = - typeof value.value.target_temperature_settings.targetTemperature.heatingTarget.value === 'number' + typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number' ? value.value.target_temperature_settings.targetTemperature.heatingTarget.value : 0.0; RESTTypeData.target_temperature_high = - typeof value.value.target_temperature_settings.targetTemperature.coolingTarget.value === 'number' + typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number' ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value : 0.0; - if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL') { + if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL') { // Target temperature is the cooling point RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.coolingTarget.value; } - if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT') { + if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT') { // Target temperature is the heating point RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.heatingTarget.value; } - if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') { + if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_RANGE') { // Target temperature is in between the heating and cooling point RESTTypeData.target_temperature = (value.value.target_temperature_settings.targetTemperature.coolingTarget.value + @@ -1450,7 +1415,7 @@ export default class NestAccfactory { } // Work out if eco mode is active and adjust temperature low/high and target - if (value.value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE') { + if (value.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE') { RESTTypeData.target_temperature_low = value.value.eco_mode_settings.ecoTemperatureHeat.value.value; RESTTypeData.target_temperature_high = value.value.eco_mode_settings.ecoTemperatureCool.value.value; if ( @@ -1482,21 +1447,21 @@ export default class NestAccfactory { // Work out current state ie: heating, cooling etc RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling if ( - value.value.hvac_control.hvacState.coolStage1Active === true || - value.value.hvac_control.hvacState.coolStage2Active === true || - value.value.hvac_control.hvacState.coolStage2Active === true + value.value?.hvac_control?.hvacState?.coolStage1Active === true || + value.value?.hvac_control?.hvacState?.coolStage2Active === true || + value.value?.hvac_control?.hvacState?.coolStage2Active === true ) { // A cooling source is on, so we're in cooling mode RESTTypeData.hvac_state = 'cooling'; } if ( - value.value.hvac_control.hvacState.heatStage1Active === true || - value.value.hvac_control.hvacState.heatStage2Active === true || - value.value.hvac_control.hvacState.heatStage3Active === true || - value.value.hvac_control.hvacState.alternateHeatStage1Active === true || - value.value.hvac_control.hvacState.alternateHeatStage2Active === true || - value.value.hvac_control.hvacState.auxiliaryHeatActive === true || - value.value.hvac_control.hvacState.emergencyHeatActive === true + value.value?.hvac_control?.hvacState?.heatStage1Active === true || + value.value?.hvac_control?.hvacState?.heatStage2Active === true || + value.value?.hvac_control?.hvacState?.heatStage3Active === true || + value.value?.hvac_control?.hvacState?.alternateHeatStage1Active === true || + value.value?.hvac_control?.hvacState?.alternateHeatStage2Active === true || + value.value?.hvac_control?.hvacState?.auxiliaryHeatActive === true || + value.value?.hvac_control?.hvacState?.emergencyHeatActive === true ) { // A heating source is on, so we're in heating mode RESTTypeData.hvac_state = 'heating'; @@ -1524,20 +1489,20 @@ export default class NestAccfactory { // Process any temperature sensors associated with this thermostat RESTTypeData.active_rcs_sensor = - typeof value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor === 'string' + value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor !== undefined ? value.value.remote_comfort_sensing_settings.activeRcsSelection.activeRcsSensor.resourceId : ''; RESTTypeData.linked_rcs_sensors = []; - if (typeof value.value?.remote_comfort_sensing_settings?.associatedRcsSensors === 'object') { + if (value.value?.remote_comfort_sensing_settings?.associatedRcsSensors !== undefined) { value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => { - if (typeof this.#rawData?.[sensor.deviceId.resourceId]?.value === 'object') { + if (this.#rawData?.[sensor.deviceId.resourceId]?.value !== undefined) { this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat // Get sensor online/offline status - // 'liveness' property doesn't appear in protobuf data for temp sensors, so we'll add that object here + // 'liveness' property doesn't appear in Protobuf data for temp sensors, so we'll add that object here this.#rawData[sensor.deviceId.resourceId].value.liveness = {}; this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_UNSPECIFIED'; - if (typeof value.value?.remote_comfort_sensing_state?.rcsSensorStatuses === 'object') { + if (value.value?.remote_comfort_sensing_state?.rcsSensorStatuses !== undefined) { Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => { if ( sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId && @@ -1558,10 +1523,9 @@ export default class NestAccfactory { ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() : ''; RESTTypeData.schedules = {}; - if ( - typeof value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints === 'object' && - value.value[RESTTypeData.schedule_mode + '_schedule_settings'].type === + value.value[RESTTypeData.schedule_mode + '_schedule_settings']?.setpoints !== undefined && + value.value[RESTTypeData.schedule_mode + '_schedule_settings']?.type === 'SET_POINT_SCHEDULE_TYPE_' + RESTTypeData.schedule_mode.toUpperCase() ) { Object.values(value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints).forEach((schedule) => { @@ -1586,7 +1550,7 @@ export default class NestAccfactory { tempDevice = process_thermostat_data(object_key, RESTTypeData); } - if (value.source === NestAccfactory.DataSource.REST) { + if (value?.source === NestAccfactory.DataSource.REST) { let RESTTypeData = {}; RESTTypeData.mac_address = value.value.mac_address; RESTTypeData.serial_number = value.value.serial_number; @@ -1610,68 +1574,54 @@ export default class NestAccfactory { RESTTypeData.backplate_temperature = value.value.backplate_temperature; RESTTypeData.current_temperature = value.value.backplate_temperature; RESTTypeData.battery_level = value.value.battery_level; - RESTTypeData.online = this.#rawData['track.' + value.value.serial_number].value.online === true; + RESTTypeData.online = this.#rawData?.['track.' + value.value.serial_number]?.value?.online === true; RESTTypeData.leaf = value.value.leaf === true; RESTTypeData.has_humidifier = value.value.has_humidifier === true; RESTTypeData.has_dehumidifier = value.value.has_dehumidifier === true; RESTTypeData.has_fan = value.value.has_fan === true; - RESTTypeData.can_cool = this.#rawData['shared.' + value.value.serial_number].value.can_cool === true; - RESTTypeData.can_heat = this.#rawData['shared.' + value.value.serial_number].value.can_heat === true; + RESTTypeData.can_cool = this.#rawData?.['shared.' + value.value.serial_number]?.value?.can_cool === true; + RESTTypeData.can_heat = this.#rawData?.['shared.' + value.value.serial_number]?.value?.can_heat === true; RESTTypeData.temperature_lock = value.value.temperature_lock === true; RESTTypeData.temperature_lock_pin_hash = value.value.temperature_lock_pin_hash; - RESTTypeData.away = false; - if ( - typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value - ?.away === 'boolean' - ) { - RESTTypeData.away = - this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]].value.away; - } - if ( - this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value - ?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY' - ) { - RESTTypeData.away = true; - } + + // Look in two possible locations for away status + RESTTypeData.away = + this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value + ?.away === true || + this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value + ?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY'; + RESTTypeData.occupancy = RESTTypeData.away === false; // Occupancy is opposite of away status ie: away is false, then occupied - RESTTypeData.vacation_mode = false; - if ( - typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value - ?.vacation_mode === 'boolean' - ) { - RESTTypeData.vacation_mode = - this.#rawData[ - 'structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1] - ].value.vacation_mode; // vacation mode - } - if ( - this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value - ?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION' - ) { - RESTTypeData.vacation_mode = true; - } + + // Look in two possible locations for vacation status + RESTTypeData.vacation_mode = + this.#rawData['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value + ?.vacation_mode === true || + this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value + ?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION'; + RESTTypeData.description = - typeof this.#rawData['shared.' + value.value.serial_number]?.value?.name === 'string' + this.#rawData?.['shared.' + value.value.serial_number]?.value?.name !== undefined ? makeHomeKitName(this.#rawData['shared.' + value.value.serial_number].value.name) : ''; RESTTypeData.location = get_location_name( - this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1], + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1], value.value.where_id, ); // Work out current mode. ie: off, cool, heat, range and get temperature low (heat) and high (cool) - RESTTypeData.hvac_mode = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type; - RESTTypeData.target_temperature_low = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low; - RESTTypeData.target_temperature_high = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high; - if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'COOL') { + RESTTypeData.hvac_mode = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_type; + RESTTypeData.target_temperature_low = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_low; + RESTTypeData.target_temperature_high = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_high; + if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'COOL') { // Target temperature is the cooling point RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high; } - if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'HEAT') { + if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'HEAT') { // Target temperature is the heating point RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low; } - if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'RANGE') { + if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'RANGE') { // Target temperature is in between the heating and cooling point RESTTypeData.target_temperature = (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low + @@ -1700,21 +1650,21 @@ export default class NestAccfactory { // Work out current state ie: heating, cooling etc RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling if ( - this.#rawData['shared.' + value.value.serial_number].value.hvac_heater_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x2_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x3_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_aux_heater_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_x2_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_emer_heat_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_state === true + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heater_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heat_x2_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heat_x3_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_aux_heater_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_alt_heat_x2_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_emer_heat_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_alt_heat_state === true ) { // A heating source is on, so we're in heating mode RESTTypeData.hvac_state = 'heating'; } if ( - this.#rawData['shared.' + value.value.serial_number].value.hvac_ac_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x2_state === true || - this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x3_state === true + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_ac_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_cool_x2_state === true || + this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_cool_x3_state === true ) { // A cooling source is on, so we're in cooling mode RESTTypeData.hvac_state = 'cooling'; @@ -1739,21 +1689,26 @@ export default class NestAccfactory { // Process any temperature sensors associated with this thermostat RESTTypeData.active_rcs_sensor = ''; RESTTypeData.linked_rcs_sensors = []; - this.#rawData['rcs_settings.' + value.value.serial_number].value.associated_rcs_sensors.forEach((sensor) => { - if (typeof this.#rawData[sensor]?.value === 'object') { - this.#rawData[sensor].value.associated_thermostat = object_key; // Sensor is linked to this thermostat - - // Is this sensor the active one? If so, get some details about it - if (this.#rawData['rcs_settings.' + value.value.serial_number].value.active_rcs_sensors.includes(sensor)) { - RESTTypeData.active_rcs_sensor = this.#rawData[sensor].value.serial_number.toUpperCase(); - RESTTypeData.current_temperature = this.#rawData[sensor].value.current_temperature; + if (this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.associated_rcs_sensors !== undefined) { + this.#rawData?.['rcs_settings.' + value.value.serial_number].value.associated_rcs_sensors.forEach((sensor) => { + if (typeof this.#rawData[sensor]?.value === 'object') { + this.#rawData[sensor].value.associated_thermostat = object_key; // Sensor is linked to this thermostat + + // Is this sensor the active one? If so, get some details about it + if ( + this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.active_rcs_sensors !== undefined && + this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.active_rcs_sensors.includes(sensor) + ) { + RESTTypeData.active_rcs_sensor = this.#rawData[sensor].value.serial_number.toUpperCase(); + RESTTypeData.current_temperature = this.#rawData[sensor].value.current_temperature; + } + RESTTypeData.linked_rcs_sensors.push(this.#rawData[sensor].value.serial_number.toUpperCase()); } - RESTTypeData.linked_rcs_sensors.push(this.#rawData[sensor].value.serial_number.toUpperCase()); - } - }); + }); + } // Get associated schedules - if (typeof this.#rawData['schedule.' + value.value.serial_number] === 'object') { + if (this.#rawData?.['schedule.' + value.value.serial_number] !== undefined) { Object.values(this.#rawData['schedule.' + value.value.serial_number].value.days).forEach((schedules) => { Object.values(schedules).forEach((schedule) => { // Fix up temperatures in the schedule @@ -1872,7 +1827,7 @@ export default class NestAccfactory { let tempDevice = {}; try { if ( - value.source === NestAccfactory.DataSource.PROTOBUF && + value?.source === NestAccfactory.DataSource.PROTOBUF && typeof value?.value?.associated_thermostat === 'string' && value?.value?.associated_thermostat !== '' ) { @@ -1881,21 +1836,21 @@ export default class NestAccfactory { // Guessing battery minimum voltage is 2v?? RESTTypeData.battery_level = scaleValue(value.value.battery.assessedVoltage.value, 2.0, 3.0, 0, 100); RESTTypeData.current_temperature = value.value.current_temperature.temperatureValue.temperature.value; - // Online status we 'faked' when processing Thermostat protobuf data + // Online status we 'faked' when processing Thermostat Protobuf data RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE'; RESTTypeData.associated_thermostat = value.value.associated_thermostat; RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : ''; RESTTypeData.location = get_location_name( - value.value.device_info.pairerId.resourceId, - value.value.device_located_settings.whereAnnotationRid.resourceId, + value.value?.device_info?.pairerId?.resourceId, + value.value?.device_located_settings?.whereAnnotationRid?.resourceId, ); RESTTypeData.active_sensor = - this.#rawData[value.value.associated_thermostat].value?.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor - ?.resourceId === object_key; + this.#rawData?.[value.value?.associated_thermostat].value?.remote_comfort_sensing_settings?.activeRcsSelection + ?.activeRcsSensor?.resourceId === object_key; tempDevice = process_kryptonite_data(object_key, RESTTypeData); } if ( - value.source === NestAccfactory.DataSource.REST && + value?.source === NestAccfactory.DataSource.REST && typeof value?.value?.associated_thermostat === 'string' && value?.value?.associated_thermostat !== '' ) { @@ -1908,7 +1863,8 @@ export default class NestAccfactory { RESTTypeData.description = value.value.description; RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id); RESTTypeData.active_sensor = - this.#rawData['rcs_settings.' + value.value.associated_thermostat].value.active_rcs_sensors.includes(object_key) === true; + this.#rawData?.['rcs_settings.' + value.value?.associated_thermostat]?.value?.active_rcs_sensors.includes(object_key) === + true; tempDevice = process_kryptonite_data(object_key, RESTTypeData); } // eslint-disable-next-line no-unused-vars @@ -1999,7 +1955,7 @@ export default class NestAccfactory { .forEach(([object_key, value]) => { let tempDevice = {}; try { - if (value.source === NestAccfactory.DataSource.PROTOBUF) { + if (value?.source === NestAccfactory.DataSource.PROTOBUF) { /* let RESTTypeData = {}; RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64'); @@ -2012,7 +1968,7 @@ export default class NestAccfactory { RESTTypeData.battery_health_state = value.value.battery_voltage_bank1.faultInformation; RESTTypeData.smoke_status = value.value.safety_alarm_smoke.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data RESTTypeData.co_status = value.value.safety_alarm_co.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data - RESTTypeData.heat_status = + // RESTTypeData.heat_status = RESTTypeData.hushed_state = value.value.safety_alarm_smoke.silenceState === 'SILENCE_STATE_SILENCED' || value.value.safety_alarm_co.silenceState === 'SILENCE_STATE_SILENCED'; @@ -2029,7 +1985,7 @@ export default class NestAccfactory { ? parseInt(value.value.legacy_protect_device_settings.replaceByDate.seconds) : 0; - RESTTypeData.removed_from_base = + // RESTTypeData.removed_from_base = RESTTypeData.topaz_hush_key = typeof value.value.safety_structure_settings.structureHushKey === 'string' ? value.value.safety_structure_settings.structureHushKey @@ -2037,19 +1993,19 @@ export default class NestAccfactory { RESTTypeData.detected_motion = value.value.legacy_protect_device_info.autoAway === false; RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : ''; RESTTypeData.location = get_location_name( - value.value.device_info.pairerId.resourceId, - value.value.device_located_settings.whereAnnotationRid.resourceId, + value.value?.device_info?.pairerId?.resourceId, + value.value?.device_located_settings?.whereAnnotationRid?.resourceId, ); tempDevice = process_protect_data(object_key, RESTTypeData); */ } - if (value.source === NestAccfactory.DataSource.REST) { + if (value?.source === NestAccfactory.DataSource.REST) { let RESTTypeData = {}; RESTTypeData.mac_address = value.value.wifi_mac_address; RESTTypeData.serial_number = value.value.serial_number; RESTTypeData.software_version = value.value.software_version; - RESTTypeData.online = this.#rawData['widget_track.' + value.value.thread_mac_address.toUpperCase()].value.online === true; + RESTTypeData.online = this.#rawData?.['widget_track.' + value?.value?.thread_mac_address.toUpperCase()]?.value?.online === true; RESTTypeData.line_power_present = value.value.line_power_present === true; RESTTypeData.wired_or_battery = value.value.wired_or_battery; RESTTypeData.battery_level = value.value.battery_level; @@ -2063,12 +2019,12 @@ export default class NestAccfactory { RESTTypeData.heat_test_passed = value.value.component_temp_test_passed === true; RESTTypeData.latest_alarm_test = value.value.latest_manual_test_end_utc_secs; RESTTypeData.self_test_in_progress = - this.#rawData['safety.' + value.value.structure_id].value.manual_self_test_in_progress === true; + this.#rawData?.['safety.' + value.value.structure_id]?.value?.manual_self_test_in_progress === true; RESTTypeData.replacement_date = value.value.replace_by_date_utc_secs; RESTTypeData.removed_from_base = value.value.removed_from_base === true; RESTTypeData.topaz_hush_key = - typeof this.#rawData['structure.' + value.value.structure_id]?.value?.topaz_hush_key === 'string' - ? this.#rawData['structure.' + value.value.structure_id].value.topaz_hush_key + typeof this.#rawData?.['structure.' + value.value.structure_id]?.value?.topaz_hush_key === 'string' + ? this.#rawData?.['structure.' + value.value.structure_id]?.value?.topaz_hush_key : ''; RESTTypeData.detected_motion = value.value.auto_away === false; RESTTypeData.description = value.value?.description; @@ -2102,6 +2058,9 @@ export default class NestAccfactory { if (data.model.toUpperCase().includes('DOORBELL') === true) { data.device_type = NestAccfactory.DeviceType.DOORBELL; } + if (data.model.toUpperCase().includes('FLOODLIGHT') === true) { + data.device_type = NestAccfactory.DeviceType.FLOODLIGHT; + } data.uuid = object_key; // Internal structure ID data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest'; data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0'; @@ -2166,13 +2125,18 @@ export default class NestAccfactory { .forEach(([object_key, value]) => { let tempDevice = {}; try { - if (value.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) { + if (value?.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) { let RESTTypeData = {}; - //RESTTypeData.mac_address = value.value.wifi_interface.macAddress.toString('hex'); - // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits. - RESTTypeData.mac_address = '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase()).toUpperCase(); + // If we haven't found a macaddress, ase a Nest Labs prefix for first 6 digits followed by a CRC24 based off serial number for last 6 digits. + RESTTypeData.mac_address = + value.value?.wifi_interface?.macAddress !== undefined + ? Buffer.from(value.value.wifi_interface.macAddress, 'base64') + : '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase()); RESTTypeData.serial_number = value.value.device_identity.serialNumber; - RESTTypeData.software_version = value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, ''); + RESTTypeData.software_version = + value.value?.floodlight_settings?.associatedFloodlightFirmwareVersion !== undefined + ? value.value.floodlight_settings.associatedFloodlightFirmwareVersion + : value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, ''); RESTTypeData.model = 'Camera'; if (value.value.device_info.typeName === 'google.resource.NeonQuartzResource') { RESTTypeData.model = 'Cam (battery)'; @@ -2198,17 +2162,20 @@ export default class NestAccfactory { if (value.value.device_info.typeName === 'nest.resource.NestHelloResource') { RESTTypeData.model = 'Doorbell (wired, 1st gen)'; } - if (value.value.device_info.typeName === 'google.resource.AzizResource') { + if ( + value.value.device_info.typeName === 'google.resource.NeonQuartzResource' || + (value.value.device_info.typeName === 'google.resource.AzizResource' && + value.value?.floodlight_settings !== undefined && + value.value?.floodlight_state !== undefined) + ) { RESTTypeData.model = 'Cam with Floodlight (wired)'; } - if (value.value.device_info.typeName === 'google.resource.GoogleNewmanResource') { - RESTTypeData.model = 'Hub Max'; - } + RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE'; - RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : ''; + RESTTypeData.description = value.value?.label?.label !== undefined ? value.value.label.label : ''; RESTTypeData.location = get_location_name( - value.value.device_info.pairerId.resourceId, - value.value.device_located_settings.whereAnnotationRid.resourceId, + value.value?.device_info?.pairerId?.resourceId, + value.value?.device_located_settings?.whereAnnotationRid?.resourceId, ); RESTTypeData.audio_enabled = value.value?.microphone_settings?.enableMicrophone === true; RESTTypeData.has_indoor_chime = @@ -2228,7 +2195,7 @@ export default class NestAccfactory { value.value.activity_zone_settings.activityZones.forEach((zone) => { RESTTypeData.activity_zones.push({ id: typeof zone.zoneProperties?.zoneId === 'number' ? zone.zoneProperties.zoneId : zone.zoneProperties.internalIndex, - name: makeHomeKitName(typeof zone.zoneProperties?.name === 'string' ? zone.zoneProperties.name : ''), + name: makeHomeKitName(zone.zoneProperties?.name !== undefined ? zone.zoneProperties.name : ''), hidden: false, uri: '', }); @@ -2244,10 +2211,18 @@ export default class NestAccfactory { RESTTypeData.streaming_host = typeof value.value?.streaming_protocol?.directHost?.value === 'string' ? value.value.streaming_protocol.directHost.value : ''; + // Floodlight settings/status + RESTTypeData.has_light = value.value?.floodlight_settings !== undefined && value.value?.floodlight_state !== undefined; + RESTTypeData.light_enabled = value.value?.floodlight_state?.currentState === 'LIGHT_STATE_ON'; + RESTTypeData.light_brightness = + value.value?.floodlight_settings?.brightness !== undefined + ? scaleValue(value.value.floodlight_settings.brightness, 0, 10, 0, 100) + : 0; + tempDevice = process_camera_doorbell_data(object_key, RESTTypeData); } - if (value.source === NestAccfactory.DataSource.REST && value.value.properties['cc2migration.overview_state'] === 'NORMAL') { + if (value?.source === NestAccfactory.DataSource.REST && value.value?.properties?.['cc2migration.overview_state'] === 'NORMAL') { // We'll only use the REST API data for Camera's which have NOT been migrated to Google Home let RESTTypeData = {}; RESTTypeData.mac_address = value.value.mac_address; @@ -2260,17 +2235,17 @@ export default class NestAccfactory { RESTTypeData.nexus_api_http_server_url = value.value.nexus_api_http_server_url; RESTTypeData.online = value.value.streaming_state.includes('offline') === false; RESTTypeData.audio_enabled = value.value.audio_input_enabled === true; - RESTTypeData.has_indoor_chime = value.value.capabilities.includes('indoor_chime') === true; - RESTTypeData.indoor_chime_enabled = value.value.properties['doorbell.indoor_chime.enabled'] === true; - RESTTypeData.has_irled = value.value.capabilities.includes('irled') === true; - RESTTypeData.irled_enabled = value.value.properties['irled.state'] !== 'always_off'; - RESTTypeData.has_statusled = value.value.capabilities.includes('statusled') === true; - RESTTypeData.has_video_flip = value.value.capabilities.includes('video.flip') === true; - RESTTypeData.video_flipped = value.value.properties['video.flipped'] === true; - RESTTypeData.statusled_brightness = value.value.properties['statusled.brightness']; - RESTTypeData.has_microphone = value.value.capabilities.includes('audio.microphone') === true; - RESTTypeData.has_speaker = value.value.capabilities.includes('audio.speaker') === true; - RESTTypeData.has_motion_detection = value.value.capabilities.includes('detectors.on_camera') === true; + RESTTypeData.has_indoor_chime = value.value?.capabilities.includes('indoor_chime') === true; + RESTTypeData.indoor_chime_enabled = value.value?.properties['doorbell.indoor_chime.enabled'] === true; + RESTTypeData.has_irled = value.value?.capabilities.includes('irled') === true; + RESTTypeData.irled_enabled = value.value?.properties['irled.state'] !== 'always_off'; + RESTTypeData.has_statusled = value.value?.capabilities.includes('statusled') === true; + RESTTypeData.has_video_flip = value.value?.capabilities.includes('video.flip') === true; + RESTTypeData.video_flipped = value.value?.properties['video.flipped'] === true; + RESTTypeData.statusled_brightness = value.value?.properties['statusled.brightness']; + RESTTypeData.has_microphone = value.value?.capabilities.includes('audio.microphone') === true; + RESTTypeData.has_speaker = value.value?.capabilities.includes('audio.speaker') === true; + RESTTypeData.has_motion_detection = value.value?.capabilities.includes('detectors.on_camera') === true; RESTTypeData.activity_zones = value.value.activity_zones; // structure elements we added RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : []; RESTTypeData.streaming_protocols = ['PROTOCOL_NEXUSTALK']; @@ -2316,7 +2291,7 @@ export default class NestAccfactory { } }); - // Process data for any structure(s) for both REST and protobuf API data + // Process data for any structure(s) for both REST and Protobuf API data // We use this to created virtual weather station(s) for each structure that has location data const process_structure_data = (object_key, data) => { let processed = {}; @@ -2392,13 +2367,12 @@ export default class NestAccfactory { .forEach(([object_key, value]) => { let tempDevice = {}; try { - if (value.source === NestAccfactory.DataSource.PROTOBUF) { + if (value?.source === NestAccfactory.DataSource.PROTOBUF) { let RESTTypeData = {}; RESTTypeData.postal_code = value.value.structure_location.postalCode.value; RESTTypeData.country_code = value.value.structure_location.countryCode.value; - RESTTypeData.city = typeof value.value.structure_location?.city === 'string' ? value.value.structure_location.city.value : ''; - RESTTypeData.state = - typeof value.value.structure_location?.state === 'string' ? value.value.structure_location.state.value : ''; + RESTTypeData.city = value.value?.structure_location?.city !== undefined ? value.value.structure_location.city.value : ''; + RESTTypeData.state = value.value?.structure_location?.state !== undefined ? value.value.structure_location.state.value : ''; RESTTypeData.latitude = value.value.structure_location.geoCoordinate.latitude; RESTTypeData.longitude = value.value.structure_location.geoCoordinate.longitude; RESTTypeData.description = @@ -2407,16 +2381,16 @@ export default class NestAccfactory { : value.value.structure_info.name; RESTTypeData.weather = value.value.weather; - // Use the REST API structure ID from the protobuf structure. This should prevent two 'weather' objects being created + // Use the REST API structure ID from the Protobuf structure. This should prevent two 'weather' objects being created let tempDevice = process_structure_data(value.value.structure_info.rtsStructureId, RESTTypeData); - tempDevice.uuid = object_key; // Use the protobuf structure ID post processing + tempDevice.uuid = object_key; // Use the Protobuf structure ID post processing } - if (value.source === NestAccfactory.DataSource.REST) { + if (value?.source === NestAccfactory.DataSource.REST) { let RESTTypeData = {}; RESTTypeData.postal_code = value.value.postal_code; RESTTypeData.country_code = value.value.country_code; - RESTTypeData.city = typeof value.value?.city === 'string' ? value.value.city : ''; - RESTTypeData.state = typeof value.value?.state === 'string' ? value.value.state : ''; + RESTTypeData.city = value.value?.city !== undefined ? value.value.city : ''; + RESTTypeData.state = value.value?.state !== undefined ? value.value.state : ''; RESTTypeData.latitude = value.value.latitude; RESTTypeData.longitude = value.value.longitude; RESTTypeData.description = @@ -2443,18 +2417,18 @@ export default class NestAccfactory { async #set(deviceUUID, values) { if ( typeof deviceUUID !== 'string' || - typeof this.#rawData[deviceUUID] !== 'object' || + typeof this.#rawData?.[deviceUUID] !== 'object' || typeof values !== 'object' || - typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object' ) { return; } if ( this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null && - this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF + this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF ) { - let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup('nest.rpc.NestTraitSetRequest'); + let TraitMap = this.#connections[this.#rawData?.[deviceUUID]?.connection].protobufRoot.lookup('nest.messages.TraitSetRequest'); let setDataToEncode = []; let protobufElement = { traitId: { @@ -2482,13 +2456,13 @@ export default class NestAccfactory { value.toUpperCase() === 'HEAT' || value.toUpperCase() === 'RANGE')) || (key === 'target_temperature' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' && typeof value === 'number') || (key === 'target_temperature_low' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' && typeof value === 'number') || (key === 'target_temperature_high' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' && typeof value === 'number') ) { // Set either the 'mode' and/or non-eco temperatures on the target thermostat @@ -2498,14 +2472,14 @@ export default class NestAccfactory { if ( key === 'target_temperature_low' || (key === 'target_temperature' && - this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT') + this.#rawData?.[deviceUUID]?.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT') ) { heatingTarget = value; } if ( key === 'target_temperature_high' || (key === 'target_temperature' && - this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL') + this.#rawData?.[deviceUUID]?.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL') ) { coolingTarget = value; } @@ -2546,13 +2520,13 @@ export default class NestAccfactory { if ( (key === 'target_temperature' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' && typeof value === 'number') || (key === 'target_temperature_low' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' && typeof value === 'number') || (key === 'target_temperature_high' && - this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' && + this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' && typeof value === 'number') ) { // Set eco mode temperatures on the target thermostat @@ -2631,7 +2605,7 @@ export default class NestAccfactory { } if (key === 'watermark.enabled' && typeof value === 'boolean') { - // Unsupported via protobuf? + // Unsupported via Protobuf? } if (key === 'audio.enabled' && typeof value === 'boolean') { @@ -2652,8 +2626,26 @@ export default class NestAccfactory { protobufElement.property.value.chimeEnabled = value; } + if (key === 'light_enabled' && typeof value === 'boolean') { + // Turn on/off light on supported camera devices + protobufElement.traitId.traitLabel = 'floodlight_state'; + protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightStateTrait'; + // eslint-disable-next-line no-undef + protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.floodlight_state); + protobufElement.property.value.currentState = value === true ? 'LIGHT_STATE_ON' : 'LIGHT_STATE_OFF'; + } + + if (key === 'light_brightness' && typeof value === 'number') { + // Set light brightness on supported camera devices + protobufElement.traitId.traitLabel = 'floodlight_settings'; + protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightSettingsTrait'; + // eslint-disable-next-line no-undef + protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.floodlight_settings); + protobufElement.property.value.brightness = value; + } + if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') { - this?.log?.debug && this.log.debug('Unknown protobuf set key for device', deviceUUID, key, value); + this?.log?.debug && this.log.debug('Unknown Protobuf set key for device', deviceUUID, key, value); } if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') { @@ -2669,80 +2661,73 @@ export default class NestAccfactory { if (setDataToEncode.length !== 0 && TraitMap !== null) { let encodedData = TraitMap.encode(TraitMap.fromObject({ set: setDataToEncode })).finish(); - let request = { - method: 'post', - url: - 'https://' + + + await fetchWrapper( + 'post', + 'https://' + this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, - 'Content-Type': 'application/x-protobuf', - 'X-Accept-Content-Transfer-Encoding': 'binary', - 'X-Accept-Response-Streaming': 'true', + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + 'User-Agent': USERAGENT, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + 'Content-Type': 'application/x-protobuf', + 'X-Accept-Content-Transfer-Encoding': 'binary', + 'X-Accept-Response-Streaming': 'true', + }, }, - data: encodedData, - }; - axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('protobuf API had error updating device traits'); - } - }) - .catch((error) => { - this?.log?.debug && - this.log.debug('protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code); - }); + encodedData, + ).catch((error) => { + this?.log?.debug && + this.log.debug('Protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code); + }); } } - if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === true) { + if (this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === true) { // Set value on Nest Camera/Doorbell await Promise.all( Object.entries(values).map(async ([key, value]) => { - let request = { - method: 'post', - url: 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties', - headers: { - referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, - 'User-Agent': USERAGENT, - 'content-type': 'application/x-www-form-urlencoded', - [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + await fetchWrapper( + 'post', + 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties', + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + 'User-Agent': USERAGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + }, + timeout: NESTAPITIMEOUT, }, - responseType: 'json', - timeout: NESTAPITIMEOUT, - data: [key] + '=' + value + '&uuid=' + deviceUUID.split('.')[1], - }; - await axios(request) - .then((response) => { - if ( - typeof response.status !== 'number' || - response.status !== 200 || - typeof response.data.status !== 'number' || - response.data.status !== 0 - ) { + [key] + '=' + value + '&uuid=' + deviceUUID.split('.')[1], + ) + .then((response) => response.json()) + .then((data) => { + if (data?.status !== 0) { throw new Error('REST API camera update for failed with error'); } }) .catch((error) => { - this?.log?.debug && + if (error?.name !== 'TimeoutError' && this?.log?.debug) { this.log.debug('REST API camera update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + } }); }), ); } - if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === false) { + if (this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === false) { // set values on other Nest devices besides cameras/doorbells await Promise.all( Object.entries(values).map(async ([key, value]) => { - let restAPIJSONData = { objects: [] }; + let subscribeJSONData = { objects: [] }; if (deviceUUID.startsWith('device.') === false) { - restAPIJSONData.objects.push({ object_key: deviceUUID, op: 'MERGE', value: { [key]: value } }); + subscribeJSONData.objects.push({ object_key: deviceUUID, op: 'MERGE', value: { [key]: value } }); } // Some elements when setting thermostat data are located in a different object locations than with the device object @@ -2763,30 +2748,25 @@ export default class NestAccfactory { ) { RESTStructureUUID = 'shared.' + deviceUUID.split('.')[1]; } - restAPIJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } }); + subscribeJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } }); } - if (restAPIJSONData.objects.length !== 0) { - let request = { - method: 'post', - url: this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put', - responseType: 'json', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + if (subscribeJSONData.objects.length !== 0) { + await fetchWrapper( + 'post', + this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put', + { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + headers: { + 'User-Agent': USERAGENT, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + }, }, - data: JSON.stringify(restAPIJSONData), - }; - await axios(request) - .then(async (response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API property update for failed with error'); - } - }) - .catch((error) => { - this?.log?.debug && - this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); - }); + JSON.stringify(subscribeJSONData), + ).catch((error) => { + this?.log?.debug && + this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + }); } }), ); @@ -2796,9 +2776,9 @@ export default class NestAccfactory { async #get(deviceUUID, values) { if ( typeof deviceUUID !== 'string' || - typeof this.#rawData[deviceUUID] !== 'object' || + typeof this.#rawData?.[deviceUUID] !== 'object' || typeof values !== 'object' || - typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object' ) { values = {}; } @@ -2810,57 +2790,45 @@ export default class NestAccfactory { values[key] = undefined; if ( - this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && + this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST && key === 'camera_snapshot' && - deviceUUID.startsWith('quartz.') === true + deviceUUID.startsWith('quartz.') === true && + typeof this.#rawData?.[deviceUUID]?.value?.nexus_api_http_server_url === 'string' && + this.#rawData[deviceUUID].value.nexus_api_http_server_url !== '' ) { // Attempt to retrieve snapshot from camera via REST API - let request = { - method: 'get', - url: this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1], - headers: { - referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, - 'User-Agent': USERAGENT, - accept: '*/*', - [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + await fetchWrapper( + 'get', + this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1], + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + 'User-Agent': USERAGENT, + [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + + this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + }, + timeout: 3000, }, - responseType: 'arraybuffer', - timeout: 3000, - }; - - // if (typeof keyValue keyValue !== '') - /* (url = - this.#rawData[deviceUUID].value.nexus_api_http_server_url + - '/event_snapshot/' + - deviceUUID.split('.')[1] + - '/' + - id + - '?crop_type=timeline&cachebuster=' + - Math.floor(Date.now() / 1000)), */ - - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API camera snapshot failed with error'); - } - - values[key] = response.data; + ) + .then((response) => response.arrayBuffer()) + .then((data) => { + values[key] = Buffer.from(data); }) .catch((error) => { - this?.log?.debug && + if (error?.name !== 'TimeoutError' && this?.log?.debug) { this.log.debug('REST API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + } }); } if ( - this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF && + this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF && this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null && this.#rawData[deviceUUID]?.value?.device_identity?.vendorProductId !== undefined && key === 'camera_snapshot' ) { - // Attempt to retrieve snapshot from camera via protobuf API + // Attempt to retrieve snapshot from camera via Protobuf API // First, request to get snapshot url image updated let commandResponse = await this.#protobufCommand(deviceUUID, [ { @@ -2872,32 +2840,28 @@ export default class NestAccfactory { }, ]); - if (commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE') { + if ( + commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE' && + typeof this.#rawData?.[deviceUUID]?.value?.upload_live_image?.liveImageUrl === 'string' && + this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl !== '' + ) { // Snapshot url image has beeen updated, so no retrieve it - let request = { - method: 'get', - url: this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl, + await fetchWrapper('get', this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl, { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, headers: { 'User-Agent': USERAGENT, - accept: '*/*', - [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]: - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value + - this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, }, - responseType: 'arraybuffer', timeout: 3000, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('protobuf API camera snapshot failed with error'); - } - - values[key] = response.data; + }) + .then((response) => response.arrayBuffer()) + .then((data) => { + values[key] = Buffer.from(data); }) .catch((error) => { - this?.log?.debug && - this.log.debug('protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug('Protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + } }); } } @@ -2908,48 +2872,46 @@ export default class NestAccfactory { this.#eventEmitter.emit(HomeKitDevice.GET + '->' + deviceUUID, values); } - async #getWeatherData(connectionType, deviceUUID, latitude, longitude) { + async #getWeatherData(connectionUUID, deviceUUID, latitude, longitude) { let weatherData = {}; if (typeof this.#rawData[deviceUUID]?.value?.weather === 'object') { - weatherData = this.#rawData[deviceUUID].value.weather; + weatherData = this.#rawData?.[deviceUUID]?.value.weather; } - let request = { - method: 'get', - url: this.#connections[connectionType].weather_url + latitude + ',' + longitude, - headers: { - 'User-Agent': USERAGENT, - }, - responseType: 'json', - timeout: NESTAPITIMEOUT, - }; - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('REST API failed to retireve weather details'); - } - - if (typeof response.data[latitude + ',' + longitude].current === 'object') { - // Store the lat/long details in the weather data object - weatherData.latitude = latitude; - weatherData.longitude = longitude; - - // Update weather data object - weatherData.current_temperature = adjustTemperature(response.data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false); - weatherData.current_humidity = response.data[latitude + ',' + longitude].current.humidity; - weatherData.condition = response.data[latitude + ',' + longitude].current.condition; - weatherData.wind_direction = response.data[latitude + ',' + longitude].current.wind_dir; - weatherData.wind_speed = response.data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h - weatherData.sunrise = response.data[latitude + ',' + longitude].current.sunrise; - weatherData.sunset = response.data[latitude + ',' + longitude].current.sunset; - weatherData.station = response.data[latitude + ',' + longitude].location.short_name; - weatherData.forecast = response.data[latitude + ',' + longitude].forecast.daily[0].condition; - } + if (typeof this.#connections?.[connectionUUID].weather_url === 'string' && this.#connections[connectionUUID].weather_url !== '') { + await fetchWrapper('get', this.#connections[connectionUUID].weather_url + latitude + ',' + longitude, { + referer: 'https://' + this.#connections[connectionUUID].referer, + headers: { + 'User-Agent': USERAGENT, + }, + timeout: NESTAPITIMEOUT, }) - .catch((error) => { - this?.log?.debug && - this.log.debug('REST API failed to retireve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code); - }); + .then((response) => response.json()) + .then((data) => { + if (data?.[latitude + ',' + longitude]?.current !== undefined) { + // Store the lat/long details in the weather data object + weatherData.latitude = latitude; + weatherData.longitude = longitude; + + // Update weather data object + weatherData.current_temperature = adjustTemperature(data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false); + weatherData.current_humidity = data[latitude + ',' + longitude].current.humidity; + weatherData.condition = data[latitude + ',' + longitude].current.condition; + weatherData.wind_direction = data[latitude + ',' + longitude].current.wind_dir; + weatherData.wind_speed = data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h + weatherData.sunrise = data[latitude + ',' + longitude].current.sunrise; + weatherData.sunset = data[latitude + ',' + longitude].current.sunset; + weatherData.station = data[latitude + ',' + longitude].location.short_name; + weatherData.forecast = data[latitude + ',' + longitude].forecast.daily[0].condition; + } + }) + .catch((error) => { + if (error?.name !== 'TimeoutError' && this?.log?.debug) { + this.log.debug('REST API failed to retrieve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code); + } + }); + } + return weatherData; } @@ -2957,9 +2919,9 @@ export default class NestAccfactory { if ( typeof deviceUUID !== 'string' || typeof this.#rawData?.[deviceUUID] !== 'object' || - this.#rawData[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF || + this.#rawData?.[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF || Array.isArray(commands === false) || - typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object' + typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object' ) { return; } @@ -2967,7 +2929,7 @@ export default class NestAccfactory { let commandResponse = undefined; let encodedData = undefined; - // Build the protobuf command object for encoding + // Build the Protobuf command object for encoding let protobufElement = { resourceRequest: { resourceId: deviceUUID, @@ -2992,37 +2954,33 @@ export default class NestAccfactory { } if (encodedData !== undefined) { - let request = { - method: 'post', - url: - 'https://' + + await fetchWrapper( + 'post', + 'https://' + this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + '/nestlabs.gateway.v1.ResourceApi/SendCommand', - headers: { - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, - 'Content-Type': 'application/x-protobuf', - 'X-Accept-Content-Transfer-Encoding': 'binary', - 'X-Accept-Response-Streaming': 'true', + { + headers: { + referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + 'User-Agent': USERAGENT, + Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + 'Content-Type': 'application/x-protobuf', + 'X-Accept-Content-Transfer-Encoding': 'binary', + 'X-Accept-Response-Streaming': 'true', + }, }, - responseType: 'arraybuffer', - data: encodedData, - }; - - await axios(request) - .then((response) => { - if (typeof response.status !== 'number' || response.status !== 200) { - throw new Error('protobuf command send failed with error'); - } - + encodedData, + ) + .then((response) => response.bytes()) + .then((data) => { commandResponse = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot .lookup('nestlabs.gateway.v1.ResourceCommandResponseFromAPI') - .decode(response.data) + .decode(data) .toJSON(); }) .catch((error) => { this?.log?.debug && - this.log.debug('protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + this.log.debug('Protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); }); } @@ -3063,10 +3021,12 @@ function makeHomeKitName(nameToMakeValid) { // Strip invalid characters to meet HomeKit naming requirements // Ensure only letters or numbers are at the beginning AND/OR end of string // Matches against uni-code characters - return nameToMakeValid - .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '') - .replace(/^[^\p{L}\p{N}]*/gu, '') - .replace(/[^\p{L}\p{N}]+$/gu, ''); + return typeof nameToMakeValid === 'string' + ? nameToMakeValid + .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '') + .replace(/^[^\p{L}\p{N}]*/gu, '') + .replace(/[^\p{L}\p{N}]+$/gu, '') + : nameToMakeValid; } function crc24(valueToHash) { @@ -3110,3 +3070,31 @@ function scaleValue(value, sourceRangeMin, sourceRangeMax, targetRangeMin, targe } return ((value - sourceRangeMin) * (targetRangeMax - targetRangeMin)) / (sourceRangeMax - sourceRangeMin) + targetRangeMin; } + +async function fetchWrapper(method, url, options, data) { + if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') { + return; + } + + if (typeof options?.timeout === 'number' && options?.timeout > 0) { + // If a timeout is specified in the options, setup here + // eslint-disable-next-line no-undef + options.signal = AbortSignal.timeout(options.timeout); + } + + options.method = method; // Set the HTTP method to use + + if (method === 'post' && typeof data !== undefined) { + // Doing a HTTP post, so include the data in the body + options.body = data; + } + + // eslint-disable-next-line no-undef + let response = await fetch(url, options); + if (response.ok === false) { + let error = new Error(response.statusText); + error.code = response.status; + throw error; + } + return response; +} diff --git a/src/thermostat.js b/src/thermostat.js index 2912c81..8dbe6ed 100644 --- a/src/thermostat.js +++ b/src/thermostat.js @@ -1,7 +1,7 @@ // Nest Thermostat // Part of homebridge-nest-accfactory // -// Code version 3/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -658,7 +658,7 @@ export default class NestThermostat extends HomeKitDevice { this?.log?.info && this.log.info( 'Fan setup on thermostat "%s" has changed. Fan was', - this.deviceData.description, + deviceData.description, this.fanService === undefined ? 'removed' : 'added', ); } @@ -693,7 +693,7 @@ export default class NestThermostat extends HomeKitDevice { this?.log?.info && this.log.info( 'Dehumidifier setup on thermostat "%s" has changed. Dehumidifier was', - this.deviceData.description, + deviceData.description, this.dehumidifierService === undefined ? 'removed' : 'added', ); } @@ -732,7 +732,7 @@ export default class NestThermostat extends HomeKitDevice { }); } - this?.log?.info && this.log.info('Heating/cooling setup on thermostat on "%s" has changed', this.deviceData.description); + this?.log?.info && this.log.info('Heating/cooling setup on thermostat on "%s" has changed', deviceData.description); } // Update current mode temperatures From a78abc3f4d79732cfbd53052de91bfc115e64a85 Mon Sep 17 00:00:00 2001 From: Yn0rt0nthec4t Date: Thu, 12 Sep 2024 17:19:45 +1000 Subject: [PATCH 3/5] 0.1.7-alpha.3 source files --- package.json | 2 +- src/HomeKitDevice.js | 9 +- src/camera.js | 26 +- src/index.js | 24 +- src/protobuf/googlehome/foyer.proto | 1 + src/protobuf/nest/messages.proto | 30 -- src/protobuf/nestlabs/eventingapi/v1.proto | 8 +- src/protobuf/nestlabs/gateway/v1.proto | 57 ++-- src/protobuf/nestlabs/gateway/v2.proto | 24 +- src/protobuf/root.proto | 1 - src/system.js | 358 ++++++++++----------- src/thermostat.js | 3 +- 12 files changed, 258 insertions(+), 285 deletions(-) delete mode 100644 src/protobuf/nest/messages.proto diff --git a/package.json b/package.json index 5dfa9db..76c69e0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "displayName": "Nest Accfactory", "name": "nest-accfactory", "homepage": "https://github.com/n0rt0nthec4t/Nest_accfactory", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.3", "description": "HomeKit integration for Nest devices using HAP-NodeJS library", "license": "Apache-2.0", "author": "n0rt0nthec4t", diff --git a/src/HomeKitDevice.js b/src/HomeKitDevice.js index ef0048c..563488a 100644 --- a/src/HomeKitDevice.js +++ b/src/HomeKitDevice.js @@ -381,6 +381,13 @@ export default class HomeKitDevice { // Send event with data to set this.#eventEmitter.emit(HomeKitDevice.SET, this.deviceData.uuid, values); + + // Update the internal data for the set values, as could take sometime once we emit the event + Object.entries(values).forEach(([key, value]) => { + if (this.deviceData[key] !== undefined) { + this.deviceData[key] = value; + } + }); } async get(values) { @@ -394,7 +401,7 @@ export default class HomeKitDevice { } // Send event with data to get - // Once get has completed, we'll get an eevent back with the requested data + // Once get has completed, we'll get an event back with the requested data this.#eventEmitter.emit(HomeKitDevice.GET, this.deviceData.uuid, values); // This should always return, but we probably should put in a timeout? diff --git a/src/camera.js b/src/camera.js index 001aebd..4a8303a 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,7 +1,7 @@ // Nest Cameras // Part of homebridge-nest-accfactory // -// Code version 8/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -37,14 +37,14 @@ export default class NestCamera extends HomeKitDevice { personTimer = undefined; // Cooldown timer for person/face events motionTimer = undefined; // Cooldown timer for motion events snapshotTimer = undefined; // Timer for cached snapshot images - cameraOfflineImage = undefined; // JPG image buffer for camera offline - cameraVideoOffImage = undefined; // JPG image buffer for camera video off lastSnapshotImage = undefined; // JPG image buffer for last camera snapshot snapshotEvent = undefined; // Event for which to get snapshot for // Internal data only for this class #hkSessions = []; // Track live and recording active sessions #recordingConfig = {}; // HomeKit Secure Video recording configuration + #cameraOfflineImage = undefined; // JPG image buffer for camera offline + #cameraVideoOffImage = undefined; // JPG image buffer for camera video off constructor(accessory, api, log, eventEmitter, deviceData) { super(accessory, api, log, eventEmitter, deviceData); @@ -52,16 +52,14 @@ export default class NestCamera extends HomeKitDevice { // buffer for camera offline jpg image let imageFile = path.resolve(__dirname + '/res/' + CAMERAOFFLINEJPGFILE); if (fs.existsSync(imageFile) === true) { - this.cameraOfflineImage = fs.readFileSync(imageFile); + this.#cameraOfflineImage = fs.readFileSync(imageFile); } // buffer for camera stream off jpg image imageFile = path.resolve(__dirname + '/res/' + CAMERAOFFJPGFILE); if (fs.existsSync(imageFile) === true) { - this.cameraVideoOffImage = fs.readFileSync(imageFile); + this.#cameraVideoOffImage = fs.readFileSync(imageFile); } - - this.set({ 'watermark.enabled': false }); // 'Try' to turn off Nest watermark in video stream } // Class functions @@ -465,12 +463,14 @@ export default class NestCamera extends HomeKitDevice { }); // ffmpeg outputs to stderr + /* this.#hkSessions[sessionID].ffmpeg.stderr.on('data', (data) => { if (data.toString().includes('frame=') === false) { // Monitor ffmpeg output while testing. Use 'ffmpeg as a debug option' this?.log?.debug && this.log.debug(data.toString()); } }); + */ this.streamer !== undefined && this.streamer.startRecordStream( @@ -594,14 +594,14 @@ export default class NestCamera extends HomeKitDevice { } } - if (this.deviceData.streaming_enabled === false && this.deviceData.online === true && this.cameraVideoOffImage !== undefined) { + if (this.deviceData.streaming_enabled === false && this.deviceData.online === true && this.#cameraVideoOffImage !== undefined) { // Return 'camera switched off' jpg to image buffer - imageBuffer = this.cameraVideoOffImage; + imageBuffer = this.#cameraVideoOffImage; } - if (this.deviceData.online === false && this.cameraOfflineImage !== undefined) { + if (this.deviceData.online === false && this.#cameraOfflineImage !== undefined) { // Return 'camera offline' jpg to image buffer - imageBuffer = this.cameraOfflineImage; + imageBuffer = this.#cameraOfflineImage; } if (imageBuffer === undefined) { @@ -778,12 +778,14 @@ export default class NestCamera extends HomeKitDevice { }); // Extra pipe, #3 for audio data // ffmpeg console output is via stderr + /* ffmpegStreaming.stderr.on('data', (data) => { if (data.toString().includes('frame=') === false) { // Monitor ffmpeg output while testing. Use 'ffmpeg as a debug option' this?.log?.debug && this.log.debug(data.toString()); } }); + */ ffmpegStreaming.on('exit', (code, signal) => { if (signal !== 'SIGKILL' || signal === null) { @@ -872,9 +874,11 @@ export default class NestCamera extends HomeKitDevice { }); // ffmpeg console output is via stderr + /* ffmpegAudioTalkback.stderr.on('data', (data) => { this?.log?.debug && this.log.debug(data.toString()); }); + */ // Write out SDP configuration // Tried to align the SDP configuration to what HomeKit has sent us in its audio request details diff --git a/src/index.js b/src/index.js index a447471..2697c42 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,7 @@ // // Supports both Nest REST and protobuf APIs for communication to Nest systems // -// Code version 11/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -70,26 +70,24 @@ function loadConfiguration(filename) { if (key === 'Connections' && typeof value === 'object') { // Array of 'connections' to different logins. Can be a combination of Nest, google and SDM Object.entries(value).forEach(([subKey, value]) => { - if (subKey === 'Nest' && typeof value === 'object' && typeof value?.access_token === 'string' && value.access_token !== '') { + if (typeof value === 'object' && typeof value?.access_token === 'string' && value.access_token !== '') { // Nest accounts access_token to use for Nest API calls - config.nest = { + config[subKey] = { access_token: value.access_token.trim(), - fieldTest: typeof value?.FieldTest === 'boolean' ? value.FieldTest : false, + fieldTest: value?.FieldTest === true, }; } - if ( - subKey === 'Google' && - typeof value === 'object' && + if (typeof value === 'object' && typeof value?.issuetoken === 'string' && value.issuetoken !== '' && typeof value?.cookie === 'string' && value.cookie !== '' ) { // Google account issue token and cookie for Nest API calls - config.google = { + config[subKey] = { issuetoken: value.issuetoken.trim(), cookie: value.cookie.trim(), - fieldTest: typeof value?.FieldTest === 'boolean' ? value.FieldTest : false, + fieldTest: value?.FieldTest === true, }; } }); @@ -97,9 +95,9 @@ function loadConfiguration(filename) { if (key === 'SessionToken' && typeof value === 'string' && value !== '') { // Nest accounts Session token to use for Nest API calls // NOTE: Legacy option. Use Connections option(s) - config.nest = { + config['legacynest'] = { access_token: value.trim(), - fieldTest: typeof loadedConfig?.FieldTest === 'boolean' ? loadedConfig.FieldTest : false, + fieldTest: value?.FieldTest === true, }; } if ( @@ -112,10 +110,10 @@ function loadConfiguration(filename) { ) { // Google account issue token and cookie for Nest API calls // NOTE: Legacy option. Use Connections option(s) - config.google = { + config['legacygoogle'] = { issuetoken: value.issuetoken.trim(), cookie: value.cookie.trim(), - fieldTest: typeof value?.FieldTest === 'boolean' ? value.FieldTest : false, + fieldTest: value?.FieldTest === true, }; } if (key === 'mDNS' && (typeof value === 'string') & (value !== '')) { diff --git a/src/protobuf/googlehome/foyer.proto b/src/protobuf/googlehome/foyer.proto index 4e58631..cb2adf1 100644 --- a/src/protobuf/googlehome/foyer.proto +++ b/src/protobuf/googlehome/foyer.proto @@ -15,6 +15,7 @@ message StructuresService { string requestId = 3; // 64 hex characters - random is OK int32 unknown1 = 4; // always 1? + int32 unknown2 = 5; // always 1? repeated DefaultHiddenDeviceType deviceTypesToUnhideArray = 10; } diff --git a/src/protobuf/nest/messages.proto b/src/protobuf/nest/messages.proto deleted file mode 100644 index dd15b84..0000000 --- a/src/protobuf/nest/messages.proto +++ /dev/null @@ -1,30 +0,0 @@ -syntax = "proto3"; - -import "../nestlabs/gateway/v2.proto"; - -package nest.messages; - -message IncomingMessage { - repeated nestlabs.gateway.v2.ResourceMeta resourceMetas = 1; - repeated nestlabs.gateway.v2.TraitState get = 3; -} - -message StreamBody { - repeated IncomingMessage message = 1; - google.rpc.Status status = 2; - repeated bytes noop = 15; -} - -message TraitSetRequest { - message TraitObject { - nestlabs.gateway.v2.TraitId traitId = 1; - google.protobuf.Any property = 2; - } - - repeated TraitObject set = 1; -} - -message SchemaVersion { - uint32 currentVersion = 1; - uint32 minCompatVersion = 2; -} diff --git a/src/protobuf/nestlabs/eventingapi/v1.proto b/src/protobuf/nestlabs/eventingapi/v1.proto index 8b7274c..f7b1bd8 100644 --- a/src/protobuf/nestlabs/eventingapi/v1.proto +++ b/src/protobuf/nestlabs/eventingapi/v1.proto @@ -3,12 +3,16 @@ syntax = "proto3"; import "google/protobuf/any.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; -import "../../nest/messages.proto"; import "../../weave/common.proto"; import "../../wdl-event-importance.proto"; package nestlabs.eventingapi.v1; +message SchemaVersion { + uint32 currentVersion = 1; + uint32 minCompatVersion = 2; +} + message EventAgent { weave.common.ResourceId deviceId = 1; ServiceId serviceId = 2; @@ -58,7 +62,7 @@ message EventHeader { EventAgent producerAgent = 22; string producerEventKey = 23; map tags = 24; - nest.messages.SchemaVersion schemaVersion = 25; + SchemaVersion schemaVersion = 25; map serviceTimestamps = 26; WdmEventFields wdmEventFields = 40; diff --git a/src/protobuf/nestlabs/gateway/v1.proto b/src/protobuf/nestlabs/gateway/v1.proto index 580cebd..30ee222 100644 --- a/src/protobuf/nestlabs/gateway/v1.proto +++ b/src/protobuf/nestlabs/gateway/v1.proto @@ -4,11 +4,15 @@ import "google/protobuf/any.proto"; import "google/protobuf/field_mask.proto"; import "google/protobuf/timestamp.proto"; import "../../google/rpc/status.proto"; -import "../../nest/messages.proto"; import "../../wdl-event-importance.proto"; package nestlabs.gateway.v1; +message SchemaVersion { + uint32 currentVersion = 1; + uint32 minCompatVersion = 2; +} + message TraitStateNotification { google.protobuf.Any state = 1; google.protobuf.FieldMask stateMask = 2; @@ -42,7 +46,7 @@ message Event { string subjectPairerId = 10; string subjectTypeName = 11; string subjectInstanceId = 12; - nest.messages.SchemaVersion schemaVersion = 13; + SchemaVersion schemaVersion = 13; } message TraitRequest { @@ -108,7 +112,7 @@ message TraitGetStateResponse { message TraitInfo { string traitType = 1; - nest.messages.SchemaVersion schemaVersion = 2; + SchemaVersion schemaVersion = 2; } message TraitUpdateStateRequest { @@ -116,7 +120,7 @@ message TraitUpdateStateRequest { google.protobuf.Any state = 2; google.protobuf.FieldMask stateMask = 3; uint64 matchPublisherVersion = 4; - nest.messages.SchemaVersion schemaVersion = 5; + SchemaVersion schemaVersion = 5; } message TraitNotifyRequest { @@ -139,7 +143,7 @@ message TraitCommand { google.protobuf.Timestamp expiryTime = 3; bytes authenticator = 4; uint64 matchPublisherVersion = 5; - nest.messages.SchemaVersion schemaVersion = 6; + SchemaVersion schemaVersion = 6; string namespaceId = 7; } @@ -183,7 +187,7 @@ message ResourceInfo { message IfaceInfo { string ifaceType = 1; repeated IfaceTraitInfo ifaceTraitInfos = 2; - nest.messages.SchemaVersion schemaVersion = 6; + SchemaVersion schemaVersion = 6; } message IfaceTraitInfo { @@ -231,43 +235,50 @@ message ResourceNotifyResponse { repeated TraitNotifyResponse traitResponses = 2; } -message ResourceCommandRequest { - ResourceRequest resourceRequest = 1; - repeated ResourceCommand resourceCommands = 2; -} - message ResourceCommand { string traitLabel = 1; google.protobuf.Any command = 2; google.protobuf.Timestamp expiryTime = 3; bytes authenticator = 4; uint64 matchPublisherVersion = 5; - nest.messages.SchemaVersion schemaVersion = 6; + SchemaVersion schemaVersion = 6; string resourceType = 7; } -message ResourceCommandResponse { +message SendCommandRequest { ResourceRequest resourceRequest = 1; - repeated TraitOperation traitOperations = 2; + repeated ResourceCommand resourceCommands = 2; } -message ResourceCommandResponseFromAPI { - repeated ResourceCommandResponse resourceCommandResponse = 1; - string unknown = 2; +message SendCommandResponse { + message ResourceCommandResponse { + ResourceRequest resourceRequest = 1; + repeated TraitOperation traitOperations = 2; + } + + repeated ResourceCommandResponse sendCommandResponse = 1; } -message BatchTraitUpdateStateRequest { - repeated TraitUpdateStateRequest requests = 1; +message BatchUpdateStateRequest { + message TraitObject { + nestlabs.gateway.v2.TraitId traitId = 1; + google.protobuf.Any property = 2; + } + + repeated TraitObject batchUpdateStateRequest = 1; } -message BatchTraitOperation { - repeated TraitOperation traitOperation = 1; +message BatchUpdateStateResponse { + message TraitOperationStateResponse { + repeated TraitOperation traitOperations = 1; + } + repeated TraitOperationStateResponse batchUpdateStateResponse = 1; } service TraitBatchApi { - rpc BatchUpdateState(BatchTraitUpdateStateRequest) returns (BatchTraitOperation); + rpc BatchUpdateState(BatchUpdateStateRequest) returns (BatchUpdateStateResponse); } service ResourceApi { - rpc SendCommand(ResourceCommandRequest) returns (ResourceCommandResponse); + rpc SendCommand(SendCommandRequest) returns (SendCommandResponse); } diff --git a/src/protobuf/nestlabs/gateway/v2.proto b/src/protobuf/nestlabs/gateway/v2.proto index 93898bc..e962d5f 100644 --- a/src/protobuf/nestlabs/gateway/v2.proto +++ b/src/protobuf/nestlabs/gateway/v2.proto @@ -3,7 +3,6 @@ syntax = "proto3"; import "google/protobuf/any.proto"; import "google/protobuf/field_mask.proto"; import "google/protobuf/timestamp.proto"; -import "../../nest/messages.proto"; import "../../nestlabs/gateway/v1.proto"; package nestlabs.gateway.v2; @@ -20,17 +19,22 @@ enum StateType { ACCEPTED = 2; } +message SchemaVersion { + uint32 currentVersion = 1; + uint32 minCompatVersion = 2; +} + message TraitMeta { string traitLabel = 1; string type = 2; - nest.messages.SchemaVersion schemaVersion = 3; + SchemaVersion schemaVersion = 3; } message IfaceMeta { string ifaceLabel = 1; string type = 2; map traitLabelMapping = 3; - nest.messages.SchemaVersion schemaVersion = 4; + SchemaVersion schemaVersion = 4; } message ResourceMeta { @@ -84,11 +88,15 @@ message TraitOperationList { } message ObserveResponse { - repeated ResourceMeta resourceMetas = 1; - bool initialResourceMetasContinue = 2; - repeated TraitState traitStates = 3; - repeated TraitOperationList traitOperationLists = 4; - google.protobuf.Timestamp currentTime = 5; + message ObserveResponse { + repeated ResourceMeta resourceMetas = 1; + bool initialResourceMetasContinue = 2; + repeated TraitState traitStates = 3; + repeated TraitOperationList traitOperationLists = 4; + google.protobuf.Timestamp currentTime = 5; + } + + repeated ObserveResponse observeResponse = 1; } service GatewayService { diff --git a/src/protobuf/root.proto b/src/protobuf/root.proto index a73de12..5119f69 100644 --- a/src/protobuf/root.proto +++ b/src/protobuf/root.proto @@ -1,6 +1,5 @@ syntax = "proto3"; -import "nest/messages.proto"; import "google/trait/product/camera.proto"; import "nest/trait/audio.proto"; import "nest/trait/detector.proto"; diff --git a/src/system.js b/src/system.js index 6e02171..357ce1f 100644 --- a/src/system.js +++ b/src/system.js @@ -778,51 +778,49 @@ export default class NestAccfactory { try { // Attempt to decode the Protobuf message(s) we extracted from the stream and get a JSON object representation decodedMessage = this.#connections[connectionUUID].protobufRoot - .lookup('nest.messages.StreamBody') + .lookup('nestlabs.gateway.v2.ObserveResponse') .decode(buffer.subarray(0, messageSize)) .toJSON(); - if (typeof decodedMessage?.message !== 'object') { - decodedMessage.message = []; - } - if (typeof decodedMessage?.message[0]?.get !== 'object') { - decodedMessage.message[0].get = []; - } - if (typeof decodedMessage?.message[0]?.resourceMetas !== 'object') { - decodedMessage.message[0].resourceMetas = []; - } // 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 - let notAcceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === false); - let acceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true); - let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel); - decodedMessage.message[0].get = - ((notAcceptedStatus = notAcceptedStatus.filter( - (trait) => difference.includes(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false, - )), - [...notAcceptedStatus, ...acceptedStatus]); - + 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 - decodedMessage.message[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/ device - deviceChanges.push({ object_key: resource.resourceId, change: 'removed' }); - } - }); + 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/ device + deviceChanges.push({ object_key: resource.resourceId, change: 'removed' }); + } + }); + } // 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?.message[0]?.get === 'object') { + if (typeof decodedMessage?.observeResponse?.[0]?.traitStates === 'object') { await Promise.all( - decodedMessage.message[0].get.map(async (trait) => { + decodedMessage.observeResponse[0].traitStates.map(async (trait) => { if (trait.traitId.traitLabel === 'configuration_done') { if ( this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true && @@ -1041,28 +1039,40 @@ export default class NestAccfactory { ) { let alerts = []; // No alerts yet - let commandResponse = await this.#protobufCommand(object.object_key, [ + let commandResponse = await this.#protobufCommand( + this.#rawData[object.object_key].connection, + 'ResourceApi', + 'SendCommand', { - 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, + resourceRequest: { + resourceId: object.object_key, + 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 ( - typeof commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow + typeof commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow ?.cameraEvent === 'object' ) { - commandResponse.resourceCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach( + commandResponse.sendCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach( (event) => { alerts.push({ playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000, @@ -1398,21 +1408,20 @@ export default class NestAccfactory { typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number' ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value : 0.0; - if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL') { - // Target temperature is the cooling point - RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.coolingTarget.value; - } - if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT') { - // Target temperature is the heating point - RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.heatingTarget.value; - } - if (value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_RANGE') { - // Target temperature is in between the heating and cooling point - RESTTypeData.target_temperature = - (value.value.target_temperature_settings.targetTemperature.coolingTarget.value + - value.value.target_temperature_settings.targetTemperature.heatingTarget.value) * - 0.5; - } + RESTTypeData.target_temperature = + value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL' && + typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number' + ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value + : value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT' && + typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number' + ? value.value.target_temperature_settings.targetTemperature.heatingTarget.value + : value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_RANGE' && + typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number' && + typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number' + ? (value.value.target_temperature_settings.targetTemperature.coolingTarget.value + + value.value.target_temperature_settings.targetTemperature.heatingTarget.value) * + 0.5 + : 0.0; // Work out if eco mode is active and adjust temperature low/high and target if (value.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE') { @@ -1519,6 +1528,7 @@ export default class NestAccfactory { } RESTTypeData.schedule_mode = + value.value?.target_temperature_settings?.targetTemperature?.setpointType !== undefined && value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() !== 'off' ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() : ''; @@ -2428,8 +2438,7 @@ export default class NestAccfactory { this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null && this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF ) { - let TraitMap = this.#connections[this.#rawData?.[deviceUUID]?.connection].protobufRoot.lookup('nest.messages.TraitSetRequest'); - let setDataToEncode = []; + let updatedTraits = []; let protobufElement = { traitId: { resourceId: deviceUUID, @@ -2466,34 +2475,37 @@ export default class NestAccfactory { typeof value === 'number') ) { // Set either the 'mode' and/or non-eco temperatures on the target thermostat - let coolingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.coolingTarget.value; - let heatingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.heatingTarget.value; + protobufElement.traitId.traitLabel = 'target_temperature_settings'; + protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait'; + protobufElement.property.value = this.#rawData[deviceUUID].value.target_temperature_settings; if ( - key === 'target_temperature_low' || - (key === 'target_temperature' && - this.#rawData?.[deviceUUID]?.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT') + (key === 'target_temperature_low' || key === 'target_temperature') && + (protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT' || + protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { - heatingTarget = value; + // Changing heating target temperature + protobufElement.property.value.targetTemperature.heatingTarget = { value: value }; } if ( - key === 'target_temperature_high' || - (key === 'target_temperature' && - this.#rawData?.[deviceUUID]?.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL') + (key === 'target_temperature_high' || key === 'target_temperature') && + (protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL' || + protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { - coolingTarget = value; + // Changing cooling target temperature + protobufElement.property.value.targetTemperature.coolingTarget = { value: value }; } - protobufElement.traitId.traitLabel = 'target_temperature_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value.targetTemperature = structuredClone(this.#rawData[deviceUUID].value.target_temperature_settings); - protobufElement.property.value.targetTemperature.setpointType = - key === 'hvac_mode' && value.toUpperCase() !== 'OFF' - ? 'SET_POINT_TYPE_' + value.toUpperCase() - : this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType; - protobufElement.property.value.targetTemperature.heatingTarget = { value: heatingTarget }; - protobufElement.property.value.targetTemperature.coolingTarget = { value: coolingTarget }; + if (key === 'hvac_mode' && value.toUpperCase() !== 'OFF') { + protobufElement.property.value.targetTemperature.setpointType = 'SET_POINT_TYPE_' + value.toUpperCase(); + protobufElement.property.value.enabled = { value: true }; + } + + if (key === 'hvac_mode' && value.toUpperCase() === 'OFF') { + protobufElement.property.value.enabled = { value: false }; + } + + // Tage 'who is doing the temperature/mode change. We are :-) protobufElement.property.value.targetTemperature.currentActorInfo = { method: 'HVAC_ACTOR_METHOD_IOS', originator: { @@ -2504,18 +2516,6 @@ export default class NestAccfactory { timeOfAction: { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 }, originatorRtsId: '', }; - protobufElement.property.value.targetTemperature.originalActorInfo = { - method: 'HVAC_ACTOR_METHOD_UNSPECIFIED', - originator: null, - timeOfAction: null, - originatorRtsId: '', - }; - protobufElement.property.value.enabled = { - value: - key === 'hvac_mode' - ? value.toUpperCase() !== 'OFF' - : this.#rawData[deviceUUID].value.target_temperature_settings.enabled.value, - }; } if ( @@ -2532,8 +2532,8 @@ export default class NestAccfactory { // Set eco mode temperatures on the target thermostat protobufElement.traitId.traitLabel = 'eco_mode_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.eco_mode_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.eco_mode_settings; + protobufElement.property.value.ecoTemperatureHeat.value.value = protobufElement.property.value.ecoTemperatureHeat.enabled === true && protobufElement.property.value.ecoTemperatureCool.enabled === false @@ -2562,8 +2562,7 @@ export default class NestAccfactory { // Set the temperature scale on the target thermostat protobufElement.traitId.traitLabel = 'display_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.display_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.display_settings; protobufElement.property.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C'; } @@ -2571,8 +2570,7 @@ export default class NestAccfactory { // Set lock mode on the target thermostat protobufElement.traitId.traitLabel = 'temperature_lock_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.temperature_lock_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.temperature_lock_settings; protobufElement.property.value.enabled = value === true; } @@ -2585,8 +2583,7 @@ export default class NestAccfactory { protobufElement.traitId.traitLabel = 'fan_control_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.fan_control_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.fan_control_settings; protobufElement.property.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 }; } @@ -2597,23 +2594,17 @@ export default class NestAccfactory { // Turn camera video on/off protobufElement.traitId.traitLabel = 'recording_toggle_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.recording_toggle_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.recording_toggle_settings; protobufElement.property.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF'; protobufElement.property.value.changeModeReason = 2; protobufElement.property.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 }; } - if (key === 'watermark.enabled' && typeof value === 'boolean') { - // Unsupported via Protobuf? - } - if (key === 'audio.enabled' && typeof value === 'boolean') { // Enable/disable microphone on camera/doorbell protobufElement.traitId.traitLabel = 'microphone_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.microphone_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.microphone_settings; protobufElement.property.value.enableMicrophone = value; } @@ -2622,7 +2613,7 @@ export default class NestAccfactory { protobufElement.traitId.traitLabel = 'doorbell_indoor_chime_settings'; protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait'; // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings); + protobufElement.property.value = this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings; protobufElement.property.value.chimeEnabled = value; } @@ -2630,8 +2621,7 @@ export default class NestAccfactory { // Turn on/off light on supported camera devices protobufElement.traitId.traitLabel = 'floodlight_state'; protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightStateTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.floodlight_state); + protobufElement.property.value = this.#rawData[deviceUUID].value.floodlight_state; protobufElement.property.value.currentState = value === true ? 'LIGHT_STATE_ON' : 'LIGHT_STATE_OFF'; } @@ -2639,49 +2629,31 @@ export default class NestAccfactory { // Set light brightness on supported camera devices protobufElement.traitId.traitLabel = 'floodlight_settings'; protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.floodlight_settings); - protobufElement.property.value.brightness = value; + protobufElement.property.value = this.#rawData[deviceUUID].value.floodlight_settings; + protobufElement.property.value.brightness = scaleValue(value, 0, 100, 0, 10); // Scale to required level } if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') { - this?.log?.debug && this.log.debug('Unknown Protobuf set key for device', deviceUUID, key, value); + this?.log?.debug && this.log.debug('Unknown Protobuf set key "%s" for device uuid "%s"', key, deviceUUID); } if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') { - let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup( - protobufElement.property.type_url.split('/')[1], - ); - protobufElement.property.value = trait.encode(trait.fromObject(protobufElement.property.value)).finish(); // eslint-disable-next-line no-undef - setDataToEncode.push(structuredClone(protobufElement)); + updatedTraits.push(structuredClone(protobufElement)); } }), ); - if (setDataToEncode.length !== 0 && TraitMap !== null) { - let encodedData = TraitMap.encode(TraitMap.fromObject({ set: setDataToEncode })).finish(); - - await fetchWrapper( - 'post', - 'https://' + - this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + - '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState', - { - headers: { - referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, - 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, - 'Content-Type': 'application/x-protobuf', - 'X-Accept-Content-Transfer-Encoding': 'binary', - 'X-Accept-Response-Streaming': 'true', - }, - }, - encodedData, - ).catch((error) => { - this?.log?.debug && - this.log.debug('Protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code); + if (updatedTraits.length !== 0) { + let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'TraitBatchApi', 'BatchUpdateState', { + batchUpdateStateRequest: updatedTraits, }); + if ( + commandResponse === undefined || + commandResponse?.batchUpdateStateResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE' + ) { + this?.log?.debug && this.log.debug('Protobuf API had error updating device traits for uuid "%s"', deviceUUID); + } } } @@ -2830,18 +2802,24 @@ export default class NestAccfactory { ) { // Attempt to retrieve snapshot from camera via Protobuf API // First, request to get snapshot url image updated - let commandResponse = await this.#protobufCommand(deviceUUID, [ - { - traitLabel: 'upload_live_image', - command: { - type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest', - value: {}, - }, + let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'ResourceApi', 'SendCommand', { + resourceRequest: { + resourceId: deviceUUID, + requestId: crypto.randomUUID(), }, - ]); + resourceCommands: [ + { + traitLabel: 'upload_live_image', + command: { + type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest', + value: {}, + }, + }, + ], + }); if ( - commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE' && + commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE' && typeof this.#rawData?.[deviceUUID]?.value?.upload_live_image?.liveImageUrl === 'string' && this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl !== '' ) { @@ -2915,55 +2893,54 @@ export default class NestAccfactory { return weatherData; } - async #protobufCommand(deviceUUID, commands) { + async #protobufCommand(connectionUUID, service, command, values) { if ( - typeof deviceUUID !== 'string' || - typeof this.#rawData?.[deviceUUID] !== 'object' || - this.#rawData?.[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF || - Array.isArray(commands === false) || - typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object' + this.#connections?.[connectionUUID]?.protobufRoot === null || + typeof service !== 'string' || + service === '' || + typeof command !== 'string' || + command === '' || + typeof values !== 'object' ) { return; } - let commandResponse = undefined; - let encodedData = undefined; + const encodeValues = (object) => { + if (typeof object === 'object' && object !== null) { + if ('type_url' in object && 'value' in object) { + // We hvae a type_url and value object at this same level, we'll treat this as requiring encoding + let TraitMap = this.#connections[connectionUUID].protobufRoot.lookup(object.type_url.split('/')[1]); + if (TraitMap !== null) { + object.value = TraitMap.encode(TraitMap.fromObject(object.value)).finish(); + } + } - // Build the Protobuf command object for encoding - let protobufElement = { - resourceRequest: { - resourceId: deviceUUID, - requestId: crypto.randomUUID(), - }, - resourceCommands: commands, + for (let key in object) { + if (object?.[key] !== undefined) { + encodeValues(object[key]); + } + } + } }; - // End code each of the commands - protobufElement.resourceCommands.forEach((command) => { - let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(command.command.type_url.split('/')[1]); - if (trait !== null) { - command.command.value = trait.encode(trait.fromObject(command.command.value)).finish(); - } - }); + // Attempt to retrieve both 'Request' and 'Reponse' traits for the associated service and command + let TraitMapRequest = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v1.' + command + 'Request'); + let TraitMapResponse = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v1.' + command + 'Response'); + let commandResponse = undefined; - let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup( - 'nestlabs.gateway.v1.ResourceCommandRequest', - ); - if (TraitMap !== null) { - encodedData = TraitMap.encode(TraitMap.fromObject(protobufElement)).finish(); - } + if (TraitMapRequest !== null && TraitMapResponse !== null) { + // Encode any trait values in our passed in object + encodeValues(values); - if (encodedData !== undefined) { + let encodedData = TraitMapRequest.encode(TraitMapRequest.fromObject(values)).finish(); await fetchWrapper( 'post', - 'https://' + - this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost + - '/nestlabs.gateway.v1.ResourceApi/SendCommand', + 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v1.' + service + '/' + command, { headers: { - referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer, + referer: 'https://' + this.#connections[connectionUUID].referer, 'User-Agent': USERAGENT, - Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token, + Authorization: 'Basic ' + this.#connections[connectionUUID].token, 'Content-Type': 'application/x-protobuf', 'X-Accept-Content-Transfer-Encoding': 'binary', 'X-Accept-Response-Streaming': 'true', @@ -2973,17 +2950,12 @@ export default class NestAccfactory { ) .then((response) => response.bytes()) .then((data) => { - commandResponse = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot - .lookup('nestlabs.gateway.v1.ResourceCommandResponseFromAPI') - .decode(data) - .toJSON(); + commandResponse = TraitMapResponse.decode(data).toJSON(); }) .catch((error) => { - this?.log?.debug && - this.log.debug('Protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code); + this?.log?.debug && this.log.debug('Protobuf service command failed with error. Error was "%s"', error?.code); }); } - return commandResponse; } } diff --git a/src/thermostat.js b/src/thermostat.js index 8dbe6ed..59afa8c 100644 --- a/src/thermostat.js +++ b/src/thermostat.js @@ -1013,8 +1013,7 @@ export default class NestThermostat extends HomeKitDevice { } if (typeof EveHomeSetData?.vacation === 'boolean') { - this.deviceData.vacation_mode = EveHomeSetData.vacation.status; - this.set({ vacation_mode: this.deviceData.vacation_mode }); + this.set({ vacation_mode: EveHomeSetData.vacation.status }); } if (typeof EveHomeSetData?.programs === 'object') { /* EveHomeSetData.programs.forEach((day) => { From 4d237787a4ccf95a8f00db3b66098f8588161cf8 Mon Sep 17 00:00:00 2001 From: Yn0rt0nthec4t Date: Fri, 13 Sep 2024 15:35:26 +1000 Subject: [PATCH 4/5] 0.1.7 Release source files --- package.json | 2 +- src/HomeKitDevice.js | 10 +- src/camera.js | 8 +- src/doorbell.js | 4 +- src/protect.js | 9 +- .../google/trait/product/camera.proto | 1 + src/protobuf/nestlabs/gateway/v1.proto | 7 +- src/protobuf/root.proto | 1 + src/protobuf/weave/trait/actuator.proto | 13 + src/system.js | 238 +++++++++++------- 10 files changed, 178 insertions(+), 115 deletions(-) create mode 100644 src/protobuf/weave/trait/actuator.proto diff --git a/package.json b/package.json index 76c69e0..c71ede3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "displayName": "Nest Accfactory", "name": "nest-accfactory", "homepage": "https://github.com/n0rt0nthec4t/Nest_accfactory", - "version": "0.1.7-alpha.3", + "version": "0.1.7", "description": "HomeKit integration for Nest devices using HAP-NodeJS library", "license": "Apache-2.0", "author": "n0rt0nthec4t", diff --git a/src/HomeKitDevice.js b/src/HomeKitDevice.js index 563488a..9e673e6 100644 --- a/src/HomeKitDevice.js +++ b/src/HomeKitDevice.js @@ -37,7 +37,7 @@ // HomeKitDevice.updateServices(deviceData) // HomeKitDevice.messageServices(type, message) // -// Code version 12/9/2024 +// Code version 13/9/2024 // Mark Hulskamp 'use strict'; @@ -104,7 +104,8 @@ export default class HomeKitDevice { this.#eventEmitter.addListener(this.deviceData.uuid, this.#message.bind(this)); } - // Make copy of current data and store in this object + // Make a clone of current data and store in this object + // Important that we done have a 'linked' cope of the object data // eslint-disable-next-line no-undef this.deviceData = structuredClone(deviceData); @@ -149,7 +150,10 @@ export default class HomeKitDevice { this.deviceData.model === '' || typeof this.deviceData?.manufacturer !== 'string' || this.deviceData.manufacturer === '' || - (this.#platform === undefined && typeof this.deviceData?.hkPairingCode !== 'string' && this.deviceData.hkPairingCode === '') || + (this.#platform === undefined && + typeof this.deviceData?.hkPairingCode !== 'string' && + (new RegExp(/^([0-9]{3}-[0-9]{2}-[0-9]{3})$/).test(this.deviceData.hkPairingCode) === true || + new RegExp(/^([0-9]{4}-[0-9]{4})$/).test(this.deviceData.hkPairingCode) === true)) || (this.#platform === undefined && typeof this.deviceData?.hkUsername !== 'string' && new RegExp(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/).test(this.deviceData.hkUsername) === false) diff --git a/src/camera.js b/src/camera.js index 4a8303a..e06a37b 100644 --- a/src/camera.js +++ b/src/camera.js @@ -99,7 +99,7 @@ export default class NestCamera extends HomeKitDevice { (value === true && this.deviceData.statusled_brightness !== 0) || (value === false && this.deviceData.statusled_brightness !== 1) ) { - this.set({ 'statusled.brightness': value === true ? 0 : 1 }); + this.set({ statusled_brightness: value === true ? 0 : 1 }); if (this?.log?.info) { this.log.info('Recording status LED on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); } @@ -119,7 +119,7 @@ export default class NestCamera extends HomeKitDevice { this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onSet((value) => { // only change IRLed status value if different than on-device if ((value === false && this.deviceData.irled_enabled === true) || (value === true && this.deviceData.irled_enabled === false)) { - this.set({ 'irled.state': value === true ? 'auto_on' : 'always_off' }); + this.set({ irled_enabled: value === true ? 'auto_on' : 'always_off' }); if (this?.log?.info) { this.log.info('Night vision on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); @@ -144,7 +144,7 @@ export default class NestCamera extends HomeKitDevice { (this.deviceData.streaming_enabled === true && value === true) ) { // Camera state does not reflect requested state, so fix - this.set({ 'streaming.enabled': value === false ? true : false }); + this.set({ streaming_enabled: value === false ? true : false }); if (this?.log?.info) { this.log.info('Camera on "%s" was turned', this.deviceData.description, value === false ? 'on' : 'off'); } @@ -178,7 +178,7 @@ export default class NestCamera extends HomeKitDevice { (this.deviceData.audio_enabled === true && value === this.hap.Characteristic.RecordingAudioActive.DISABLE) || (this.deviceData.audio_enabled === false && value === this.hap.Characteristic.RecordingAudioActive.ENABLE) ) { - this.set({ 'audio.enabled': value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false }); + this.set({ audio_enabled: value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false }); if (this?.log?.info) { this.log.info( 'Audio recording on "%s" was turned', diff --git a/src/doorbell.js b/src/doorbell.js index 2cb49a8..fe9a6e4 100644 --- a/src/doorbell.js +++ b/src/doorbell.js @@ -1,7 +1,7 @@ // Nest Doorbell(s) // Part of homebridge-nest-accfactory // -// Code version 8/9/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -41,7 +41,7 @@ export default class NestDoorbell extends NestCamera { this.switchService.getCharacteristic(this.hap.Characteristic.On).onSet((value) => { if (value !== this.deviceData.indoor_chime_enabled) { // only change indoor chime status value if different than on-device - this.set({ 'doorbell.indoor_chime.enabled': value }); + this.set({ indoor_chime_enabled: value }); this?.log?.info && this.log.info('Indoor chime on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off'); } diff --git a/src/protect.js b/src/protect.js index 7cfaf97..aa43583 100644 --- a/src/protect.js +++ b/src/protect.js @@ -1,7 +1,7 @@ // Nest Protect // Part of homebridge-nest-accfactory // -// Code version 9/8/2024 +// Code version 12/9/2024 // Mark Hulskamp 'use strict'; @@ -80,7 +80,7 @@ export default class NestProtect extends HomeKitDevice { EveSmoke_alarmtest: this.deviceData.self_test_in_progress, EveSmoke_heatstatus: this.deviceData.heat_status, EveSmoke_hushedstate: this.deviceData.hushed_state, - EveSmoke_statusled: this.deviceData.ntp_green_led, + EveSmoke_statusled: this.deviceData.ntp_green_led_enable, EveSmoke_smoketestpassed: this.deviceData.smoke_test_passed, EveSmoke_heattestpassed: this.deviceData.heat_test_passed, }); @@ -202,7 +202,7 @@ export default class NestProtect extends HomeKitDevice { this.deviceData.latest_alarm_test = deviceData.latest_alarm_test; this.deviceData.self_test_in_progress = deviceData.self_test_in_progress; this.deviceData.heat_status = deviceData.heat_status; - this.deviceData.ntp_green_led = deviceData.ntp_green_led; + this.deviceData.ntp_green_led_enable = deviceData.ntp_green_led_enable; this.deviceData.smoke_test_passed = deviceData.smoke_test_passed; this.deviceData.heat_test_passed = deviceData.heat_test_passed; this.historyService.updateEveHome(this.smokeService, this.#EveHomeGetcommand.bind(this)); @@ -216,7 +216,7 @@ export default class NestProtect extends HomeKitDevice { EveHomeGetData.lastalarmtest = this.deviceData.latest_alarm_test; EveHomeGetData.alarmtest = this.deviceData.self_test_in_progress; EveHomeGetData.heatstatus = this.deviceData.heat_status; - EveHomeGetData.statusled = this.deviceData.ntp_green_led; + EveHomeGetData.statusled = this.deviceData.ntp_green_led_enable; EveHomeGetData.smoketestpassed = this.deviceData.smoke_test_passed; EveHomeGetData.heattestpassed = this.deviceData.heat_test_passed; EveHomeGetData.hushedstate = this.deviceData.hushed_state; @@ -233,7 +233,6 @@ export default class NestProtect extends HomeKitDevice { //this.log.info('Eve Smoke Alarm test', (EveHomeSetData.alarmtest === true ? 'start' : 'stop')); } if (typeof EveHomeSetData?.statusled === 'boolean') { - this.deviceData.ntp_green_led = EveHomeSetData.statusled; // Do quick status update as setting Nest values does take sometime this.set({ ntp_green_led_enable: EveHomeSetData.statusled }); } } diff --git a/src/protobuf/google/trait/product/camera.proto b/src/protobuf/google/trait/product/camera.proto index 8edf532..d0206a7 100644 --- a/src/protobuf/google/trait/product/camera.proto +++ b/src/protobuf/google/trait/product/camera.proto @@ -50,4 +50,5 @@ message FloodlightSettingsTrait { OnOffTrigger liveViewingTrigger = 4; DaylightSensitivity daylightSensitivity = 5; OnOffTrigger cameraEventTrigger = 6; + uin32 lightState = 7; } diff --git a/src/protobuf/nestlabs/gateway/v1.proto b/src/protobuf/nestlabs/gateway/v1.proto index 30ee222..b2f8bb4 100644 --- a/src/protobuf/nestlabs/gateway/v1.proto +++ b/src/protobuf/nestlabs/gateway/v1.proto @@ -260,12 +260,7 @@ message SendCommandResponse { } message BatchUpdateStateRequest { - message TraitObject { - nestlabs.gateway.v2.TraitId traitId = 1; - google.protobuf.Any property = 2; - } - - repeated TraitObject batchUpdateStateRequest = 1; + repeated TraitUpdateStateRequest batchUpdateStateRequest = 1; } message BatchUpdateStateResponse { diff --git a/src/protobuf/root.proto b/src/protobuf/root.proto index 5119f69..7a0e5b8 100644 --- a/src/protobuf/root.proto +++ b/src/protobuf/root.proto @@ -23,6 +23,7 @@ import "nest/trait/service.proto"; import "nest/trait/structure.proto"; import "nest/trait/ui.proto"; import "weave/common.proto"; +import "weave/trait/actuator.proto"; import "weave/trait/audio.proto"; import "weave/trait/description.proto"; import "weave/trait/heartbeat.proto"; diff --git a/src/protobuf/weave/trait/actuator.proto b/src/protobuf/weave/trait/actuator.proto new file mode 100644 index 0000000..a3eb2c6 --- /dev/null +++ b/src/protobuf/weave/trait/actuator.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +import "google/protobuf/wrappers.proto"; + +package weave.trait.actuator; + +message OnOffTrait { + message SetStateRequest { + bool on = 1; + } + + bool on = 1; +} diff --git a/src/system.js b/src/system.js index 357ce1f..34e5b82 100644 --- a/src/system.js +++ b/src/system.js @@ -1,7 +1,7 @@ // Nest System communications // Part of homebridge-nest-accfactory // -// Code version 12/9/2024 +// Code version 13/9/2024 // Mark Hulskamp 'use strict'; @@ -1982,7 +1982,7 @@ export default class NestAccfactory { RESTTypeData.hushed_state = value.value.safety_alarm_smoke.silenceState === 'SILENCE_STATE_SILENCED' || value.value.safety_alarm_co.silenceState === 'SILENCE_STATE_SILENCED'; - RESTTypeData.ntp_green_led = value.value.night_time_promise_settings.greenLedEnabled === true; + RESTTypeData.ntp_green_led_enable = value.value.night_time_promise_settings.greenLedEnabled === true; RESTTypeData.smoke_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_SMOKE') === false; RESTTypeData.heat_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_TEMP') === false; RESTTypeData.latest_alarm_test = @@ -2024,7 +2024,7 @@ export default class NestAccfactory { RESTTypeData.co_status = value.value.co_status; RESTTypeData.heat_status = value.value.heat_status; RESTTypeData.hushed_state = value.value.hushed_state === true; - RESTTypeData.ntp_green_led = value.value.ntp_green_led_enable === true; + RESTTypeData.ntp_green_led_enable = value.value.ntp_green_led_enable === true; RESTTypeData.smoke_test_passed = value.value.component_smoke_test_passed === true; RESTTypeData.heat_test_passed = value.value.component_temp_test_passed === true; RESTTypeData.latest_alarm_test = value.value.latest_manual_test_end_utc_secs; @@ -2121,7 +2121,6 @@ export default class NestAccfactory { 'nest.resource.NestCamIQResource', 'nest.resource.NestCamIQOutdoorResource', 'nest.resource.NestHelloResource', - 'google.resource.AzizResource', 'google.resource.GoogleNewmanResource', ]; Object.entries(this.#rawData) @@ -2148,7 +2147,11 @@ export default class NestAccfactory { ? value.value.floodlight_settings.associatedFloodlightFirmwareVersion : value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, ''); RESTTypeData.model = 'Camera'; - if (value.value.device_info.typeName === 'google.resource.NeonQuartzResource') { + if ( + value.value.device_info.typeName === 'google.resource.NeonQuartzResource' && + value.value?.floodlight_settings === undefined && + value.value?.floodlight_state === undefined + ) { RESTTypeData.model = 'Cam (battery)'; } if (value.value.device_info.typeName === 'google.resource.GreenQuartzResource') { @@ -2173,10 +2176,9 @@ export default class NestAccfactory { RESTTypeData.model = 'Doorbell (wired, 1st gen)'; } if ( - value.value.device_info.typeName === 'google.resource.NeonQuartzResource' || - (value.value.device_info.typeName === 'google.resource.AzizResource' && - value.value?.floodlight_settings !== undefined && - value.value?.floodlight_state !== undefined) + value.value.device_info.typeName === 'google.resource.NeonQuartzResource' && + value.value?.floodlight_settings !== undefined && + value.value?.floodlight_state !== undefined ) { RESTTypeData.model = 'Cam with Floodlight (wired)'; } @@ -2440,11 +2442,12 @@ export default class NestAccfactory { ) { let updatedTraits = []; let protobufElement = { - traitId: { + traitRequest: { resourceId: deviceUUID, traitLabel: '', + requestId: crypto.randomUUID(), }, - property: { + state: { type_url: '', value: {}, }, @@ -2453,9 +2456,9 @@ export default class NestAccfactory { await Promise.all( Object.entries(values).map(async ([key, value]) => { // Reset elements at start of loop - protobufElement.traitId.traitLabel = ''; - protobufElement.property.type_url = ''; - protobufElement.property.value = {}; + protobufElement.traitRequest.traitLabel = ''; + protobufElement.state.type_url = ''; + protobufElement.state.value = {}; if ( (key === 'hvac_mode' && @@ -2475,38 +2478,38 @@ export default class NestAccfactory { typeof value === 'number') ) { // Set either the 'mode' and/or non-eco temperatures on the target thermostat - protobufElement.traitId.traitLabel = 'target_temperature_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.target_temperature_settings; + protobufElement.traitRequest.traitLabel = 'target_temperature_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.target_temperature_settings; if ( (key === 'target_temperature_low' || key === 'target_temperature') && - (protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT' || - protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') + (protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT' || + protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { // Changing heating target temperature - protobufElement.property.value.targetTemperature.heatingTarget = { value: value }; + protobufElement.state.value.targetTemperature.heatingTarget = { value: value }; } if ( (key === 'target_temperature_high' || key === 'target_temperature') && - (protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL' || - protobufElement.property.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') + (protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL' || + protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') ) { // Changing cooling target temperature - protobufElement.property.value.targetTemperature.coolingTarget = { value: value }; + protobufElement.state.value.targetTemperature.coolingTarget = { value: value }; } if (key === 'hvac_mode' && value.toUpperCase() !== 'OFF') { - protobufElement.property.value.targetTemperature.setpointType = 'SET_POINT_TYPE_' + value.toUpperCase(); - protobufElement.property.value.enabled = { value: true }; + protobufElement.state.value.targetTemperature.setpointType = 'SET_POINT_TYPE_' + value.toUpperCase(); + protobufElement.state.value.enabled = { value: true }; } if (key === 'hvac_mode' && value.toUpperCase() === 'OFF') { - protobufElement.property.value.enabled = { value: false }; + protobufElement.state.value.enabled = { value: false }; } // Tage 'who is doing the temperature/mode change. We are :-) - protobufElement.property.value.targetTemperature.currentActorInfo = { + protobufElement.state.value.targetTemperature.currentActorInfo = { method: 'HVAC_ACTOR_METHOD_IOS', originator: { resourceId: Object.keys(this.#rawData) @@ -2530,48 +2533,48 @@ export default class NestAccfactory { typeof value === 'number') ) { // Set eco mode temperatures on the target thermostat - protobufElement.traitId.traitLabel = 'eco_mode_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.eco_mode_settings; + protobufElement.traitRequest.traitLabel = 'eco_mode_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.eco_mode_settings; - protobufElement.property.value.ecoTemperatureHeat.value.value = - protobufElement.property.value.ecoTemperatureHeat.enabled === true && - protobufElement.property.value.ecoTemperatureCool.enabled === false + protobufElement.state.value.ecoTemperatureHeat.value.value = + protobufElement.state.value.ecoTemperatureHeat.enabled === true && + protobufElement.state.value.ecoTemperatureCool.enabled === false ? value - : protobufElement.property.value.ecoTemperatureHeat.value.value; - protobufElement.property.value.ecoTemperatureCool.value.value = - protobufElement.property.value.ecoTemperatureHeat.enabled === false && - protobufElement.property.value.ecoTemperatureCool.enabled === true + : protobufElement.state.value.ecoTemperatureHeat.value.value; + protobufElement.state.value.ecoTemperatureCool.value.value = + protobufElement.state.value.ecoTemperatureHeat.enabled === false && + protobufElement.state.value.ecoTemperatureCool.enabled === true ? value - : protobufElement.property.value.ecoTemperatureCool.value.value; - protobufElement.property.value.ecoTemperatureHeat.value.value = - protobufElement.property.value.ecoTemperatureHeat.enabled === true && - protobufElement.property.value.ecoTemperatureCool.enabled === true && + : protobufElement.state.value.ecoTemperatureCool.value.value; + protobufElement.state.value.ecoTemperatureHeat.value.value = + protobufElement.state.value.ecoTemperatureHeat.enabled === true && + protobufElement.state.value.ecoTemperatureCool.enabled === true && key === 'target_temperature_low' ? value - : protobufElement.property.value.ecoTemperatureHeat.value.value; - protobufElement.property.value.ecoTemperatureCool.value.value = - protobufElement.property.value.ecoTemperatureHeat.enabled === true && - protobufElement.property.value.ecoTemperatureCool.enabled === true && + : protobufElement.state.value.ecoTemperatureHeat.value.value; + protobufElement.state.value.ecoTemperatureCool.value.value = + protobufElement.state.value.ecoTemperatureHeat.enabled === true && + protobufElement.state.value.ecoTemperatureCool.enabled === true && key === 'target_temperature_high' ? value - : protobufElement.property.value.ecoTemperatureCool.value.value; + : protobufElement.state.value.ecoTemperatureCool.value.value; } if (key === 'temperature_scale' && typeof value === 'string' && (value.toUpperCase() === 'C' || value.toUpperCase() === 'F')) { // Set the temperature scale on the target thermostat - protobufElement.traitId.traitLabel = 'display_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.display_settings; - protobufElement.property.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C'; + protobufElement.traitRequest.traitLabel = 'display_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.display_settings; + protobufElement.state.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C'; } if (key === 'temperature_lock' && typeof value === 'boolean') { // Set lock mode on the target thermostat - protobufElement.traitId.traitLabel = 'temperature_lock_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.temperature_lock_settings; - protobufElement.property.value.enabled = value === true; + protobufElement.traitRequest.traitLabel = 'temperature_lock_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.temperature_lock_settings; + protobufElement.state.value.enabled = value === true; } if (key === 'fan_state' && typeof value === 'boolean') { @@ -2581,10 +2584,10 @@ export default class NestAccfactory { ? Math.floor(Date.now() / 1000) + this.#rawData[deviceUUID].value.fan_control_settings.timerDuration.seconds : 0; - protobufElement.traitId.traitLabel = 'fan_control_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.fan_control_settings; - protobufElement.property.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 }; + protobufElement.traitRequest.traitLabel = 'fan_control_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.fan_control_settings; + protobufElement.state.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 }; } //if (key === 'statusled.brightness' @@ -2592,52 +2595,82 @@ export default class NestAccfactory { if (key === 'streaming.enabled' && typeof value === 'boolean') { // Turn camera video on/off - protobufElement.traitId.traitLabel = 'recording_toggle_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.recording_toggle_settings; - protobufElement.property.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF'; - protobufElement.property.value.changeModeReason = 2; - protobufElement.property.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 }; + protobufElement.traitRequest.traitLabel = 'recording_toggle_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.recording_toggle_settings; + protobufElement.state.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF'; + protobufElement.state.value.changeModeReason = 2; + protobufElement.state.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 }; } if (key === 'audio.enabled' && typeof value === 'boolean') { // Enable/disable microphone on camera/doorbell - protobufElement.traitId.traitLabel = 'microphone_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.microphone_settings; - protobufElement.property.value.enableMicrophone = value; + protobufElement.traitRequest.traitLabel = 'microphone_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.microphone_settings; + protobufElement.state.value.enableMicrophone = value; } - if (key === 'doorbell.indoor_chime.enabled' && typeof value === 'boolean') { + if (key === 'indoor_chime_enabled' && typeof value === 'boolean') { // Enable/disable chime status on doorbell - protobufElement.traitId.traitLabel = 'doorbell_indoor_chime_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait'; - // eslint-disable-next-line no-undef - protobufElement.property.value = this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings; - protobufElement.property.value.chimeEnabled = value; + protobufElement.traitRequest.traitLabel = 'doorbell_indoor_chime_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings; + protobufElement.state.value.chimeEnabled = value; } if (key === 'light_enabled' && typeof value === 'boolean') { - // Turn on/off light on supported camera devices - protobufElement.traitId.traitLabel = 'floodlight_state'; - protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightStateTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.floodlight_state; - protobufElement.property.value.currentState = value === true ? 'LIGHT_STATE_ON' : 'LIGHT_STATE_OFF'; + // Turn on/off light on supported camera devices. Need to find the related or SERVICE__ object for teh device + let serviceUUID = undefined; + if (this.#rawData[deviceUUID].value?.related_resources?.relatedResources !== undefined) { + Object.values(this.#rawData[deviceUUID].value?.related_resources?.relatedResources).forEach((values) => { + if ( + values?.resourceTypeName?.resourceName === 'google.resource.AzizResource' && + values?.resourceId?.resourceId.startsWith('SERVICE_') === true + ) { + serviceUUID = values.resourceId.resourceId; + } + }); + + if (serviceUUID !== undefined) { + let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'ResourceApi', 'SendCommand', { + resourceRequest: { + resourceId: serviceUUID, + requestId: crypto.randomUUID(), + }, + resourceCommands: [ + { + traitLabel: 'on_off', + command: { + type_url: 'type.nestlabs.com/weave.trait.actuator.OnOffTrait.SetStateRequest', + value: { + on: value, + }, + }, + }, + ], + }); + + if (commandResponse.sendCommandResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE') { + this?.log?.debug && this.log.debug('Protobuf API had error setting light status on uuid "%s"', deviceUUID); + } + } + } } if (key === 'light_brightness' && typeof value === 'number') { // Set light brightness on supported camera devices - protobufElement.traitId.traitLabel = 'floodlight_settings'; - protobufElement.property.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightSettingsTrait'; - protobufElement.property.value = this.#rawData[deviceUUID].value.floodlight_settings; - protobufElement.property.value.brightness = scaleValue(value, 0, 100, 0, 10); // Scale to required level + protobufElement.traitRequest.traitLabel = 'floodlight_settings'; + protobufElement.state.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightSettingsTrait'; + protobufElement.state.value = this.#rawData[deviceUUID].value.floodlight_settings; + protobufElement.state.value.brightness = scaleValue(value, 0, 100, 0, 10); // Scale to required level } - if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') { + if (protobufElement.traitRequest.traitLabel === '' || protobufElement.state.type_url === '') { this?.log?.debug && this.log.debug('Unknown Protobuf set key "%s" for device uuid "%s"', key, deviceUUID); } - if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') { + if (protobufElement.traitRequest.traitLabel !== '' && protobufElement.state.type_url !== '') { // eslint-disable-next-line no-undef updatedTraits.push(structuredClone(protobufElement)); } @@ -2661,6 +2694,17 @@ export default class NestAccfactory { // Set value on Nest Camera/Doorbell await Promise.all( Object.entries(values).map(async ([key, value]) => { + const SETPROPERTIES = { + indoor_chime_enabled: 'doorbell.indoor_chime.enabled', + statusled_brightness: 'statusled.brightness', + irled_enabled: 'irled.state', + streaming_enabled: 'streaming.enabled', + audio_enabled: 'audio.enabled', + }; + + // Transform key to correct set camera properties key + key = SETPROPERTIES[key] !== undefined ? SETPROPERTIES[key] : key; + await fetchWrapper( 'post', 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties', @@ -2866,12 +2910,12 @@ export default class NestAccfactory { }) .then((response) => response.json()) .then((data) => { - if (data?.[latitude + ',' + longitude]?.current !== undefined) { - // Store the lat/long details in the weather data object - weatherData.latitude = latitude; - weatherData.longitude = longitude; + // Store the lat/long details in the weather data object + weatherData.latitude = latitude; + weatherData.longitude = longitude; - // Update weather data object + // Update weather data + if (data?.[latitude + ',' + longitude]?.current !== undefined) { weatherData.current_temperature = adjustTemperature(data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false); weatherData.current_humidity = data[latitude + ',' + longitude].current.humidity; weatherData.condition = data[latitude + ',' + longitude].current.condition; @@ -2879,9 +2923,15 @@ export default class NestAccfactory { weatherData.wind_speed = data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h weatherData.sunrise = data[latitude + ',' + longitude].current.sunrise; weatherData.sunset = data[latitude + ',' + longitude].current.sunset; - weatherData.station = data[latitude + ',' + longitude].location.short_name; - weatherData.forecast = data[latitude + ',' + longitude].forecast.daily[0].condition; } + weatherData.station = + data[latitude + ',' + longitude]?.location?.short_name !== undefined + ? data[latitude + ',' + longitude].location.short_name + : ''; + weatherData.forecast = + data[latitude + ',' + longitude]?.forecast?.daily?.[0]?.condition !== undefined + ? data[latitude + ',' + longitude].forecast.daily[0].condition + : ''; }) .catch((error) => { if (error?.name !== 'TimeoutError' && this?.log?.debug) { @@ -2908,7 +2958,7 @@ export default class NestAccfactory { const encodeValues = (object) => { if (typeof object === 'object' && object !== null) { if ('type_url' in object && 'value' in object) { - // We hvae a type_url and value object at this same level, we'll treat this as requiring encoding + // We have a type_url and value object at this same level, we'll treat this a trait requiring encoding let TraitMap = this.#connections[connectionUUID].protobufRoot.lookup(object.type_url.split('/')[1]); if (TraitMap !== null) { object.value = TraitMap.encode(TraitMap.fromObject(object.value)).finish(); @@ -2953,7 +3003,7 @@ export default class NestAccfactory { commandResponse = TraitMapResponse.decode(data).toJSON(); }) .catch((error) => { - this?.log?.debug && this.log.debug('Protobuf service command failed with error. Error was "%s"', error?.code); + this?.log?.debug && this.log.debug('Protobuf gateway service command failed with error. Error was "%s"', error?.code); }); } return commandResponse; From e9ebad71577f0c0bee6e256541c19d25e6d1384b Mon Sep 17 00:00:00 2001 From: Yn0rt0nthec4t Date: Fri, 13 Sep 2024 15:40:08 +1000 Subject: [PATCH 5/5] 0.1.7 Release source files --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209cecf..13c68f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ All notable changes to `Nest_accfactory` will be documented in this file. This project tries to adhere to [Semantic Versioning](http://semver.org/). +## 0.1.7 (2024-09-13) -## 0.1.6 () +- General code cleanup and bug fixes +- External dependancy reductions, dropped pbf and axios libraries +- Nest Cam with Floodlight support with light on/off and brightness control +- Fixed issued with setting range temperatures on Nest Thermostat(s) + +## 0.1.6 (2024-0-07) - Common code bases between my two projects, homebridge-nest-accfactory and Nest_accfactory - Configuration file format has change, but for this version, we'll handle the existing one