UNPKG

homebridge-nest-accfactory

Version:

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

1,153 lines (1,013 loc) 59.6 kB
// Nest Cameras // Part of homebridge-nest-accfactory // // Code version 2025/03/19 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import EventEmitter from 'node:events'; import { Buffer } from 'node:buffer'; import { setTimeout, clearTimeout } from 'node:timers'; import process from 'node:process'; import child_process from 'node:child_process'; import net from 'node:net'; import dgram from 'node:dgram'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Define our modules import HomeKitDevice from './HomeKitDevice.js'; import NexusTalk from './nexustalk.js'; import WebRTC from './webrtc.js'; const CAMERAOFFLINEJPGFILE = 'Nest_camera_offline.jpg'; // Camera offline jpg image file const CAMERAOFFJPGFILE = 'Nest_camera_off.jpg'; // Camera video off jpg image file const CAMERATRANSFERJPGFILE = 'Nest_camera_transfer.jpg'; // Camera transferring jpg image file const MP4BOX = 'mp4box'; // MP4 box fragement event for HKSV recording 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 streamer = undefined; // Streamer object for live/recording stream motionServices = undefined; // Object of Camera/Doorbell motion sensor(s) batteryService = undefined; // If a camera has a battery <-- todo operatingModeService = undefined; // Link to camera/doorbell operating mode service personTimer = undefined; // Cooldown timer for person/face events motionTimer = undefined; // Cooldown timer for motion events snapshotTimer = undefined; // Timer for cached snapshot images 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 #cameraTransferringImage = undefined; // JPG image buffer for camera transferring between Nest/Google Home constructor(accessory, api, log, eventEmitter, deviceData) { super(accessory, api, log, eventEmitter, deviceData); // buffer for camera offline jpg image let imageFile = path.resolve(__dirname + '/res/' + CAMERAOFFLINEJPGFILE); if (fs.existsSync(imageFile) === true) { 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); } // buffer for camera transferring jpg image imageFile = path.resolve(__dirname + '/res/' + CAMERATRANSFERJPGFILE); if (fs.existsSync(imageFile) === true) { this.#cameraTransferringImage = fs.readFileSync(imageFile); } } // Class functions addServices(hapController = this.hap.CameraController) { // Setup motion services if (this.motionServices === undefined) { this.createCameraMotionServices(); } // Setup HomeKit camera/doorbell controller if (this.controller === undefined && typeof hapController === 'function') { // Need to cleanup the CameraOperatingMode service. This is to allow seamless configuration // switching between enabling hksv or not // Thanks to @bcullman (Brad Ullman) for catching this this.accessory.removeService(this.accessory.getService(this.hap.Service.CameraOperatingMode)); this.controller = new hapController(this.generateControllerOptions()); this.accessory.configureController(this.controller); } // Setup additional services/characteristics after we have a controller created 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({ uuid: this.deviceData.nest_google_uuid, 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({ uuid: this.deviceData.nest_google_uuid, 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'); } } }); 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({ uuid: this.deviceData.nest_google_uuid, 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({ uuid: this.deviceData.nest_google_uuid, 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; }); } } if (this.deviceData.migrating === true) { // Migration happening between Nest <-> Google Home apps this?.log?.warn && this.log.warn('Migration between Nest <-> Google Home apps is underway for "%s"', this.deviceData.description); } if ( (this.deviceData.streaming_protocols.includes('PROTOCOL_WEBRTC') === false && this.deviceData.streaming_protocols.includes('PROTOCOL_NEXUSTALK') === false) || (this.deviceData.streaming_protocols.includes('PROTOCOL_WEBRTC') === true && WebRTC === undefined) || (this.deviceData.streaming_protocols.includes('PROTOCOL_NEXUSTALK') === true && NexusTalk === 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 ( this.deviceData?.eveHistory === true && typeof this.motionServices?.[1]?.service === 'object' && typeof this.historyService?.linkToEveHome === 'function' ) { this.historyService.linkToEveHome(this.motionServices[1].service, { description: this.deviceData.description, }); } // Create extra details for output let postSetupDetails = []; this.deviceData.hksv === true && postSetupDetails.push( 'HomeKit Secure Video support' + (this.streamer?.isBuffering() === true ? ' and recording buffer started' : ''), ); this.deviceData.localAccess === true && postSetupDetails.push('Local access'); return postSetupDetails; } removeServices() { // Clean up our camera object since this device is being removed clearTimeout(this.motionTimer); clearTimeout(this.personTimer); clearTimeout(this.snapshotTimer); this.motionTimer = undefined; this.personTimer = undefined; this.snapshotTimer = undefined; this.streamer !== undefined && this.streamer.stopEverything(); // Stop any on-going HomeKit sessions, either live or recording // We'll terminate any ffmpeg, rtpSpliter etc processes this.#hkSessions.forEach((session) => { if (typeof session.rtpSplitter?.close === 'function') { session.rtpSplitter.close(); } session.ffmpeg.forEach((ffmpeg) => { ffmpeg.kill('SIGKILL'); }); if (session?.eventEmitter instanceof EventEmitter === true) { session.eventEmitter.removeAllListeners(MP4BOX); } }); // Remove any motion services we created 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.streamer = undefined; this.controller = undefined; } // Taken and adapted from: // https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts async *handleRecordingStreamRequest(sessionID) { if (this.deviceData?.ffmpeg?.binary === undefined) { this?.log?.warn && this.log.warn( 'Received request to start recording for "%s" however we do not have an ffmpeg binary present', this.deviceData.description, ); return; } if ( this.motionServices?.[1]?.service !== undefined && this.motionServices[1].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value === false ) { // Should only be recording if motion detected. // Sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occuring this?.log?.debug && this.log.debug('Received request to commence recording for "%s" however we have not detected any motion'); 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 support', this.deviceData.description, ); return; } // Build our ffmpeg command string for the liveview video/audio stream let commandLine = [ '-hide_banner', '-nostats', '-use_wallclock_as_timestamps 1', '-fflags +discardcorrupt', '-max_delay 500000', '-flags low_delay', '-f h264', '-i pipe:0', ]; let includeAudio = false; if ( this.deviceData.audio_enabled === true && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive) .value === this.hap.Characteristic.RecordingAudioActive.ENABLE && this.streamer?.codecs?.audio === 'aac' && this.deviceData?.ffmpeg?.libfdk_aac === true ) { // Audio data only on extra pipe created in spawn command commandLine.push('-f aac -i pipe:3'); includeAudio = true; } if ( this.deviceData.audio_enabled === true && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive) .value === this.hap.Characteristic.RecordingAudioActive.ENABLE && this.streamer?.codecs?.audio === 'opus' && this.deviceData?.ffmpeg?.libopus === true ) { // Audio data only on extra pipe created in spawn command commandLine.push('-i pipe:3'); includeAudio = true; } // Build our video command for ffmpeg commandLine.push( '-map 0:v:0', '-codec:v libx264', '-preset veryfast', '-profile:v ' + (this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.HIGH ? 'high' : this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.MAIN ? 'main' : 'baseline'), '-level:v ' + (this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL4_0 ? '4.0' : this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL3_2 ? '3.2' : '3.1'), '-noautoscale', '-bf 0', '-filter:v fps=fps=' + this.#recordingConfig.videoCodec.resolution[2], '-g:v ' + (this.#recordingConfig.videoCodec.resolution[2] * this.#recordingConfig.videoCodec.parameters.iFrameInterval) / 1000, '-b:v ' + this.#recordingConfig.videoCodec.parameters.bitRate + 'k', '-bufsize ' + 2 * this.#recordingConfig.videoCodec.parameters.bitRate + 'k', '-fps_mode passthrough', '-reset_timestamps 1', '-video_track_timescale 90000', '-movflags frag_keyframe+empty_moov+default_base_moof', ); // We have seperate video and audio streams that need to be muxed together if audio enabled if (includeAudio === true) { let audioSampleRates = ['8', '16', '24', '32', '44.1', '48']; commandLine.push( '-map 1:a:0', '-codec:a libfdk_aac', '-profile:a aac_low', '-ar ' + audioSampleRates[this.#recordingConfig.audioCodec.samplerate] + 'k', '-b:a ' + this.#recordingConfig.audioCodec.bitrate + 'k', '-ac ' + this.#recordingConfig.audioCodec.audioChannels, ); } commandLine.push('-f mp4 pipe:1'); // output to stdout in mp4 // Start our ffmpeg recording process and stream from our streamer // video is pipe #1 // audio is pipe #3 if including audio this?.log?.debug && this.log.debug( 'ffmpeg process for recording stream from "%s" will be called using the following commandline', this.deviceData.description, commandLine.join(' ').toString(), ); let ffmpegRecording = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), { env: process.env, stdio: ['pipe', 'pipe', 'pipe', 'pipe'], }); // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video. let mp4FragmentData = []; let mp4boxes = []; let eventEmitter = new EventEmitter(); ffmpegRecording.stdout.on('data', (data) => { // Process the mp4 data from our socket connection and convert into mp4 fragment boxes we need mp4FragmentData = mp4FragmentData.length === 0 ? data : Buffer.concat([mp4FragmentData, data]); while (mp4FragmentData.length >= 8) { let boxSize = mp4FragmentData.slice(0, 4).readUInt32BE(0); // Includes header and data size if (mp4FragmentData.length < boxSize) { // We dont have enough data in the buffer yet to process the full mp4 box // so, exit loop and await more data break; } // Add it to our queue to be pushed out through the generator function. if (Array.isArray(mp4boxes) === true && eventEmitter !== undefined) { mp4boxes.push({ header: mp4FragmentData.slice(0, 8), type: mp4FragmentData.slice(4, 8).toString(), data: mp4FragmentData.slice(8, boxSize), }); eventEmitter.emit(MP4BOX); } // Remove the section of data we've just processed from our buffer mp4FragmentData = mp4FragmentData.slice(boxSize); } }); ffmpegRecording.on('exit', (code, signal) => { if (signal !== 'SIGKILL' || signal === null) { this?.log?.error && this.log.error('ffmpeg recording process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code); } if (this.#hkSessions?.[sessionID] !== undefined) { delete this.#hkSessions[sessionID]; } }); // eslint-disable-next-line no-unused-vars ffmpegRecording.on('error', (error) => { // Empty }); // ffmpeg console output is via stderr ffmpegRecording.stderr.on('data', (data) => { if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) { // Monitor ffmpeg output this?.log?.debug && this.log.debug(data.toString()); } }); // Start the appropriate streamer this.streamer !== undefined && this.streamer.startRecordStream(sessionID, ffmpegRecording.stdin, ffmpegRecording?.stdio?.[3] ? ffmpegRecording.stdio[3] : null); // Store our ffmpeg sessions this.#hkSessions[sessionID] = {}; this.#hkSessions[sessionID].eventEmitter = eventEmitter; this.#hkSessions[sessionID].ffmpeg = ffmpegRecording; // Store ffmpeg process ID this?.log?.info && this.log.info('Started recording from "%s" %s', this.deviceData.description, includeAudio === false ? 'without audio' : ''); // Loop generating MOOF/MDAT box pairs for HomeKit Secure Video. // HAP-NodeJS cancels this async generator function when recording completes also let segment = []; for (;;) { if (this.#hkSessions?.[sessionID] === undefined || this.#hkSessions?.[sessionID]?.ffmpeg === undefined) { // Our session object is not present // ffmpeg recorder process is not present // so finish up the loop break; } if (mp4boxes?.length === 0 && eventEmitter !== undefined) { // since the ffmpeg recorder process hasn't notified us of any mp4 fragment boxes, wait until there are some await EventEmitter.once(eventEmitter, MP4BOX); } let mp4box = mp4boxes.shift(); if (typeof mp4box !== 'object') { // Not an mp4 fragment box, so try again continue; } // Queue up this fragment mp4 segment segment.push(mp4box.header, mp4box.data); if (mp4box.type === 'moov' || mp4box.type === 'mdat') { yield { data: Buffer.concat(segment), isLast: false }; segment = []; } } } closeRecordingStream(sessionID, closeReason) { // Stop the associated recording stream this.streamer !== undefined && this.streamer.stopRecordStream(sessionID); if (typeof this.#hkSessions?.[sessionID] === 'object') { if (this.#hkSessions[sessionID]?.ffmpeg !== undefined) { // Kill the ffmpeg recorder process this.#hkSessions[sessionID].ffmpeg.kill('SIGKILL'); } if (this.#hkSessions[sessionID]?.eventEmitter !== undefined) { this.#hkSessions[sessionID].eventEmitter.emit(MP4BOX); // This will ensure we cleanly exit out from our segment generator this.#hkSessions[sessionID].eventEmitter.removeAllListeners(MP4BOX); // Tidy up our event listeners } delete this.#hkSessions[sessionID]; } // Log recording finished messages depending on reason if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) { this?.log?.info && this.log.info('Completed recording from "%s"', this.deviceData.description); } else { this?.log?.warn && this.log.warn( 'Recording from "%s" completed with error. Reason was "%s"', this.deviceData.description, this.hap.HDSProtocolSpecificErrorReason[closeReason], ); } } updateRecordingActive(enableRecording) { 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); this.streamer.startBuffering(); } 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); } } updateRecordingConfiguration(recordingConfig) { this.#recordingConfig = recordingConfig; // Store the recording configuration HKSV has provided } async handleSnapshotRequest(snapshotRequestDetails, callback) { // snapshotRequestDetails.reason === ResourceRequestReason.PERIODIC // snapshotRequestDetails.reason === ResourceRequestReason.EVENT // Get current image from camera/doorbell let imageBuffer = undefined; if (this.deviceData.migrating === false && this.deviceData.streaming_enabled === true && this.deviceData.online === true) { let response = await this.get({ uuid: this.deviceData.nest_google_uuid, 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 clearTimeout(this.snapshotTimer); this.snapshotTimer = setTimeout(() => { this.lastSnapshotImage = undefined; }, SNAPSHOTCACHETIMEOUT); } } if ( this.deviceData.migrating === false && this.deviceData.streaming_enabled === false && this.deviceData.online === true && this.#cameraVideoOffImage !== undefined ) { // Return 'camera switched off' jpg to image buffer imageBuffer = this.#cameraVideoOffImage; } if (this.deviceData.migrating === false && this.deviceData.online === false && this.#cameraOfflineImage !== undefined) { // Return 'camera offline' jpg to image buffer imageBuffer = this.#cameraOfflineImage; } if (this.deviceData.migrating === true && this.#cameraTransferringImage !== undefined) { // Return 'camera transferring' jpg to image buffer imageBuffer = this.#cameraTransferringImage; } if (imageBuffer === undefined) { // If we get here, we have no snapshot image // We'll use the last success snapshop as long as its within a certain time period imageBuffer = this.lastSnapshotImage; } callback(imageBuffer?.length === 0 ? 'Unable to obtain Camera/Doorbell snapshot' : null, imageBuffer); } async prepareStream(request, callback) { const getPort = async (options) => { return new Promise((resolve, reject) => { let server = net.createServer(); server.unref(); server.on('error', reject); server.listen(options, () => { let port = server.address().port; server.close(() => { resolve(port); // return port }); }); }); }; // Generate streaming session information let sessionInfo = { address: request.targetAddress, videoPort: request.video.port, localVideoPort: await getPort(), videoCryptoSuite: request.video.srtpCryptoSuite, videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), videoSSRC: this.hap.CameraController.generateSynchronisationSource(), audioPort: request.audio.port, localAudioPort: await getPort(), audioTalkbackPort: await getPort(), rptSplitterPort: await getPort(), audioCryptoSuite: request.video.srtpCryptoSuite, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), audioSSRC: this.hap.CameraController.generateSynchronisationSource(), rtpSplitter: null, ffmpeg: [], // Array of ffmpeg processes we create for streaming video/audio and audio talkback video: null, audio: null, }; // Build response back to HomeKit with the details filled out // 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:', ''); } let response = { address: request.sourceAddress, // IP Address version must match video: { port: sessionInfo.localVideoPort, ssrc: sessionInfo.videoSSRC, srtp_key: request.video.srtp_key, srtp_salt: request.video.srtp_salt, }, audio: { port: sessionInfo.rptSplitterPort, ssrc: sessionInfo.audioSSRC, srtp_key: request.audio.srtp_key, srtp_salt: request.audio.srtp_salt, }, }; this.#hkSessions[request.sessionID] = sessionInfo; // Store the session information callback(undefined, response); } async handleStreamRequest(request, callback) { // 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 support', this.deviceData.description, ); } if (request.type === this.hap.StreamRequestTypes.START && this.deviceData?.ffmpeg?.binary === 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 ( request.type === this.hap.StreamRequestTypes.START && this.streamer !== undefined && this.deviceData?.ffmpeg?.binary !== undefined ) { // Build our ffmpeg command string for the liveview video/audio stream let commandLine = [ '-hide_banner', '-nostats', '-use_wallclock_as_timestamps 1', '-fflags +discardcorrupt', '-max_delay 500000', '-flags low_delay', '-f h264', '-i pipe:0', ]; let includeAudio = false; if ( this.deviceData.audio_enabled === true && this.streamer?.codecs?.audio === 'aac' && this.deviceData?.ffmpeg?.libfdk_aac === true ) { // Audio data only on extra pipe created in spawn command commandLine.push('-f aac -i pipe:3'); includeAudio = true; } if (this.deviceData.audio_enabled === true && this.streamer?.codecs?.audio === 'opus' && this.deviceData?.ffmpeg?.libopus === true) { // Audio data only on extra pipe created in spawn command commandLine.push('-i pipe:3'); includeAudio = true; } // Build our video command for ffmpeg commandLine.push( '-map 0:v:0', '-codec:v copy', '-fps_mode passthrough', '-reset_timestamps 1', '-video_track_timescale 90000', '-payload_type ' + request.video.pt, '-ssrc ' + this.#hkSessions[request.sessionID].videoSSRC, '-f rtp', '-srtp_out_suite ' + this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].videoCryptoSuite], '-srtp_out_params ' + this.#hkSessions[request.sessionID].videoSRTP.toString('base64') + ' srtp://' + this.#hkSessions[request.sessionID].address + ':' + this.#hkSessions[request.sessionID].videoPort + '?rtcpport=' + this.#hkSessions[request.sessionID].videoPort + '&pkt_size=' + request.video.mtu, ); // We have seperate video and audio streams that need to be muxed together if audio enabled if (includeAudio === true) { if (request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD) { commandLine.push('-map 1:a:0', '-codec:a libfdk_aac', '-profile:a aac_eld'); } if (request.audio.codec === this.hap.AudioStreamingCodecType.OPUS) { commandLine.push( '-map 1:a:0', '-codec:a libopus', '-application lowdelay', '-frame_duration ' + request.audio.packet_time.toString(), ); } commandLine.push( '-flags +global_header', '-ar ' + request.audio.sample_rate + 'k', '-b:a ' + request.audio.max_bit_rate + 'k', '-ac ' + request.audio.channel, '-payload_type ' + request.audio.pt, '-ssrc ' + this.#hkSessions[request.sessionID].audioSSRC, '-f rtp', '-srtp_out_suite ' + this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite], '-srtp_out_params ' + this.#hkSessions[request.sessionID].audioSRTP.toString('base64') + ' srtp://' + this.#hkSessions[request.sessionID].address + ':' + this.#hkSessions[request.sessionID].audioPort + '?rtcpport=' + this.#hkSessions[request.sessionID].audioPort + '&localrtcpport=' + this.#hkSessions[request.sessionID].localAudioPort + '&pkt_size=188', ); } // Start our ffmpeg streaming process and stream from our streamer // video is pipe #1 // audio is pipe #3 if including audio this?.log?.debug && this.log.debug( 'ffmpeg process for live streaming from "%s" will be called using the following commandline', this.deviceData.description, commandLine.join(' ').toString(), ); let ffmpegStreaming = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), { env: process.env, stdio: ['pipe', 'pipe', 'pipe', 'pipe'], }); ffmpegStreaming.on('exit', (code, signal) => { if (signal !== 'SIGKILL' || signal === null) { this?.log?.error && this.log.error( 'ffmpeg video/audio live streaming process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code, ); // Clean up or streaming request, but calling it again with a 'STOP' reques this.handleStreamRequest({ type: this.hap.StreamRequestTypes.STOP, sessionID: request.sessionID }, null); } }); // ffmpeg console output is via stderr ffmpegStreaming.stderr.on('data', (data) => { if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) { // Monitor ffmpeg output this?.log?.debug && this.log.debug(data.toString()); } }); // eslint-disable-next-line no-unused-vars ffmpegStreaming.on('error', (error) => { // Empty }); // We only enable two/way audio on camera/doorbell if we have the required libraries in ffmpeg AND two-way/audio is enabled let ffmpegAudioTalkback = null; // No ffmpeg process for return audio yet if ( ((this.streamer.codecs.talk === 'speex' && this.deviceData?.ffmpeg?.libspeex === true) || (this.streamer.codecs.talk === 'opus' && this.deviceData?.ffmpeg?.libopus === true)) && this.deviceData?.ffmpeg?.libfdk_aac === true && this.deviceData.audio_enabled === true && this.deviceData.has_speaker === true && this.deviceData.has_microphone === true ) { // Setup RTP splitter for two/away audio this.#hkSessions[request.sessionID].rtpSplitter = dgram.createSocket('udp4'); this.#hkSessions[request.sessionID].rtpSplitter.bind(this.#hkSessions[request.sessionID].rptSplitterPort); this.#hkSessions[request.sessionID].rtpSplitter.on('error', () => { this.#hkSessions[request.sessionID].rtpSplitter.close(); }); this.#hkSessions[request.sessionID].rtpSplitter.on('message', (message) => { let payloadType = message.readUInt8(1) & 0x7f; if (payloadType === request.audio.pt) { // Audio payload type from HomeKit should match our payload type for audio if (message.length > 50) { // Only send on audio data if we have a longer audio packet. // (not sure it makes any difference, as under iOS 15 packets are roughly same length) this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort); } } else { this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].localAudioPort); // Send RTCP to return audio as a heartbeat this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort); } }); // Build ffmpeg command let commandLine = [ '-hide_banner -nostats', '-protocol_whitelist pipe,udp,rtp', '-f sdp', '-codec:a libfdk_aac', '-i pipe:0', '-map 0:a', ]; if (this.streamer.codecs.talk === 'speex') { commandLine.push('-codec:a libspeex', '-frames_per_packet 4', '-vad 1', '-ac 1', '-ar 16k'); } if (this.streamer.codecs.talk === 'opus') { commandLine.push('-codec:a libopus', '-application lowdelay', '-ac 2', '-ar 48k'); } commandLine.push('-f data pipe:1'); this?.log?.debug && this.log.debug( 'ffmpeg process for talkback on "%s" will be called using the following commandline', this.deviceData.description, commandLine.join(' ').toString(), ); ffmpegAudioTalkback = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), { env: process.env, }); ffmpegAudioTalkback.on('exit', (code, signal) => { if (signal !== 'SIGKILL' || signal === null) { this?.log?.error && this.log.error( 'ffmpeg audio talkback streaming process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code, ); // Clean up or streaming request, but calling it again with a 'STOP' request this.handleStreamRequest({ type: this.hap.StreamRequestTypes.STOP, sessionID: request.sessionID }, null); } }); // eslint-disable-next-line no-unused-vars ffmpegAudioTalkback.on('error', (error) => { // Empty }); // ffmpeg console output is via stderr ffmpegAudioTalkback.stderr.on('data', (data) => { if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) { // Monitor ffmpeg output 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 let sdpResponse = [ 'v=0', 'o=- 0 0 IN ' + (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') + ' ' + this.#hkSessions[request.sessionID].address, 's=HomeKit Audio Talkback', 'c=IN ' + (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') + ' ' + this.#hkSessions[request.sessionID].address, 't=0 0', 'm=audio ' + this.#hkSessions[request.sessionID].audioTalkbackPort + ' RTP/AVP ' + request.audio.pt, 'b=AS:' + request.audio.max_bit_rate, 'a=ptime:' + request.audio.packet_time, ]; if (request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD) { sdpResponse.push( 'a=rtpmap:' + request.audio.pt + ' MPEG4-GENERIC/' + request.audio.sample_rate * 1000 + '/' + request.audio.channel, 'a=fmtp:' + request.audio.pt + ' profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8F0212C00BC00', ); } if (request.audio.codec === this.hap.AudioStreamingCodecType.OPUS) { sdpResponse.push( 'a=rtpmap:' + request.audio.pt + ' opus/' + request.audio.sample_rate * 1000 + '/' + request.audio.channel, 'a=fmtp:' + request.audio.pt + ' minptime=10;useinbandfec=1"', ); } sdpResponse.push( 'a=crypto:1 ' + this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite] + ' inline:' + this.#hkSessions[request.sessionID].audioSRTP.toString('base64'), ); ffmpegAudioTalkback.stdin.write(sdpResponse.join('\r\n')); ffmpegAudioTalkback.stdin.end(); } this?.log?.info && this.log.info( 'Live stream started on "%s" %s', this.deviceData.description, ffmpegAudioTalkback?.stdout ? 'with two-way audio' : '', ); // Start the appropriate 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 ffmpegAudioTalkback && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegAudioTalkback); // Store ffmpeg audio return process ID this.#hkSessions[request.sessionID].video = request.video; // Cache the video request details this.#hkSessions[request.sessionID].audio = request.audio; // Cache the audio request details } if (request.type === this.hap.StreamRequestTypes.STOP && typeof this.#hkSessions[request.sessionID] === 'object') { this.streamer !== undefined && this.streamer.stopLiveStream(request.sessionID); // Close HomeKit session this.controller.forceStopStreamingSession(request.sessionID); // Close off any running ffmpeg and/or splitter processes we created if (typeof this.#hkSessions[request.sessionID]?.rtpSplitter?.close === 'function') { this.#hkSessions[request.sessionID].rtpSplitter.close(); } this.#hkSessions[request.sessionID].ffmpeg.forEach((ffmpeg) => { ffmpeg.kill('SIGKILL'); }); delete this.#hkSessions[request.sessionID]; this?.log?.info && this.log.info('Live stream stopped from "%s"', this.deviceData.description); } if (request.type === this.hap.StreamRequestTypes.RECONFIGURE && typeof this.#hkSessions[request.sessionID] === 'object') { this?.log?.debug && this.log.debug('Unsupported reconfiguration request for live stream on "%s"', this.deviceData.description); } if (typeof callback === 'function') { callback(); // do callback if defined } } updateServices(deviceData) { if (typeof deviceData !== 'object' || this.controller === undefined) { return; } if (this.deviceData.migrating === false && deviceData.migrating === true) { // Migration happening between Nest <-> Google Home apps. We'll stop any active streams, close the current streaming object this?.log?.warn && this.log.warn('Migration between Nest <-> Google Home apps has started for "%s"', deviceData.description); this.streamer !== undefined && this.streamer.stopEverything(); this.streamer = undefined; } if (this.deviceData.migrating === true && deviceData.migrating === false) { // Migration has completed between Nest <-> Google Home apps this?.log?.success && this.log.success('Migration between Nest <-> Google Home apps has completed for "%s"', deviceData.description); } // Handle case of changes in streaming protocols OR just finished migration between Nest <-> Google Home apps if (this.streamer === undefined && deviceData.migrating === false) { if (JSON.stringify(deviceData.streaming_protocols) !== JSON.stringify(this.deviceData.streaming_protocols)) { this?.log?.warn && this.log.warn('Available streaming protocols have changed for "%s"', deviceData.description); this.streamer !== undefined && this.streamer.stopEverything(); this.streamer = undefined; } if (deviceData.streaming_protocols.includes('PROTOCOL_WEBRTC') === true && WebRTC !== undefined) { this?.log?.debug && this.log.debug('Using WebRTC streamer for "%s"', deviceData.description); this.streamer = new WebRTC(deviceData, { log: this.log, buffer: deviceData.hksv === true && this?.controller?.recordingManagement?.recordingManagementService !== undefined && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === this.hap.Characteristic.Active.ACTIVE, }); } if (deviceData.streaming_protocols.includes('PROTOCOL_NEXUSTALK') === true && NexusTalk !== undefined) { this?.log?.debug && this.log.debug('Using NexusTalk streamer for "%s"', deviceData.description); this.streamer = new NexusTalk(deviceData, { log: this.log, buffer: deviceData.hksv === true && this?.controller?.recordingManagement?.recordingManagementService !== undefined && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === this.hap.Characteristic.Active.ACTIVE, }); } } // Check to see if any activity zones were added for both non-HKSV and HKSV enabled devices deviceData.activity_zones.forEach((zone) => { if ( JSON.stringify(deviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones) && (this.deviceData.hksv === false || (this.deviceData.hksv === true && zone.id === 1)) ) { if (this.motionServices?.[zone.id]?.service === undefined) { // Zone doesn't have an associated motion sensor, so add one let tempService = this.accessory.addService(this.hap.Service.MotionSensor, zone.id === 1 ? '' : zone.name, zone.id); if (tempService.testCharacteristic(this.hap.Characteristic.Active) === false) { tempService.addCharacteristic(this.hap.Characteristic.Active); } tempService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially this.motionServices[zone.id] = { service: tempService, timer: undefined }; } } }); // Check to see if any activity zones were removed for both non-HKSV and HKSV enabled devices // We'll also update the online status of the camera in the motion service here Object.entries(this.motionServices).forEach(([zoneID, service]) => { // Set online status service.service.updateCharacteristic( this.hap.Characteristic.Active, deviceData.online === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE, ); if (JSON.stringify(deviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones) && zoneID !== 1) { if (deviceData.activity_zones.findIndex(({ id }) => id === zoneID) === -1) { // Motion service we created doesn't appear in zone list anymore, so assume deleted this.accessory.removeService(service.service); delete this.motionServices[zoneID]; } } }); if (this.operatingModeService !== undefined) { // Update camera off/on status this.operatingModeService.updateCharacteristic( this.hap.Characteristic.ManuallyDisabled, deviceData.streaming_enabled === false ? this.hap.Characteristic.ManuallyDisabled.DISABLED : this.hap.Characteristic.ManuallyDisabled.ENABLED, ); if (deviceData.has_statusled === true) { // Set camera recording indicator. This cannot be turned off on Nest Cameras/Doorbells // 0 = auto // 1 = low // 2 = high this.operatingModeService.updateCharacteristic( this.hap.Characteristic.CameraOperatingModeIndicator, deviceData.statusled_brightness !== 1, ); } if (deviceData.has_irled === true) { // Set nightvision status in HomeKit this.operatingModeService.updateCharacteristic(this.hap.Characteristic.NightVision, deviceData.irled_enabled); } if (deviceData.has_video_flip === true) { // Update image flip status this.operatingModeService.updateCharacteristic(this.hap.Characteristic.ImageRotation, deviceData.video_flipped === true ? 180 : 0); } } if (deviceData.hksv === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) { // Update recording audio status this.controller.recordingManagement.recordingManagementService.updateCharacteristic( this.hap.Characteristic.RecordingAudioActive, deviceData.audio_enabled === true ? this.hap.Characteristic.RecordingAudioActive.ENABLE : this.hap.Characteristic.RecordingAudioActive.DISABLE, ); } if (this.controller?.microphoneService !== undefined) { // Update microphone volume if specified //this.controller.microphoneService.updateCharacteristic(this.hap.Characteristic.Volu