UNPKG

homebridge-nest-accfactory

Version:

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

1,262 lines (1,094 loc) 88.9 kB
// Nest Cameras and Doorbells - HomeKit integration // Part of homebridge-nest-accfactory // // HomeKit accessory implementation for Nest Cameras, Doorbells, and Floodlight Cameras. // Integrates camera streaming, HomeKit Secure Video (HKSV), motion events, and snapshots // into HomeKit using HAP-NodeJS controllers. // // Responsibilities: // - Configure HomeKit CameraController or DoorbellController services // - Manage live streaming and recording session lifecycle for HomeKit // - Select and initialise the appropriate streaming backend (WebRTC or NexusTalk) // - Coordinate ffmpeg for HomeKit live streaming, talkback, and HKSV recording // - Handle motion events, cooldown timing, and Eve history integration // - Provide snapshot handling with fallback images // - Translate Nest / Google camera data into HomeKit-facing state // // Services: // - CameraController or DoorbellController (primary streaming service) // - MotionSensor (motion detection with eventSnapshot support) // - RecordingManagement (HomeKit Secure Video configuration and recording state) // - Battery (optional, for battery-powered models) // // Features: // - Streaming via WebRTC and NexusTalk protocols // - HomeKit Secure Video live streaming and recording support // - Stream copy or transcoding via ffmpeg (configurable per device) // - Motion-triggered recording with prebuffer support // - Two-way audio support where available and supported by ffmpeg // - Snapshot handling with fallback support for offline or unavailable states // - Night vision, status LED, microphone, speaker, and image rotation controls // - Battery level monitoring for supported models // - Eve Home activity history integration when enabled // // Notes: // - Streaming transport is handled by protocol-specific streamer modules // - ffmpeg is used for HomeKit-compatible live streaming, talkback, and HKSV recording // - Motion events trigger HKSV recording and HomeKit automations // - Snapshot fallback images are used for offline, disabled, or migration states // // Mark Hulskamp 'use strict'; // Define nodejs module requirements import EventEmitter from 'node:events'; import { Buffer } from 'node:buffer'; import dgram from 'node:dgram'; import fs from 'node:fs'; import path from 'node:path'; // Define our modules import HomeKitDevice from '../HomeKitDevice.js'; import Streamer from '../streamer.js'; import NexusTalk from '../nexustalk.js'; import WebRTC from '../webrtc.js'; import FFmpeg from '../ffmpeg.js'; import { processSoftwareVersion, parseDurationToSeconds, scaleValue } from '../utils.js'; import { buildMappedObject, createMappingContext } from '../translator.js'; // Define constants import { DATA_SOURCE, PROTOBUF_RESOURCES, __dirname, RESOURCE_PATH, RESOURCE_IMAGES, DEVICE_TYPE, TIMERS, LOW_BATTERY_LEVEL, LOG_LEVELS, } from '../consts.js'; const MP4BOX = 'mp4box'; const STREAMING_PROTOCOL = { WEBRTC: 'PROTOCOL_WEBRTC', NEXUSTALK: 'PROTOCOL_NEXUSTALK', MPEGDASH: 'PROTOCOL_MPEGDASH', }; const STREAMERS = { [STREAMING_PROTOCOL.WEBRTC]: { module: WebRTC, label: 'WebRTC streamer' }, [STREAMING_PROTOCOL.NEXUSTALK]: { module: NexusTalk, label: 'NexusTalk streamer' }, }; const PREBUFFER_LENGTH = 4000; export default class NestCamera extends HomeKitDevice { static TYPE = DEVICE_TYPE.CAMERA; static VERSION = '2026.05.06'; // Code version controller = undefined; // HomeKit Camera/Doorbell controller service streamer = undefined; // Streamer object for live/recording stream motionService = undefined; // Single motion sensor for camera(s) batteryService = undefined; // Service for Camera/doorbell with battery ffmpeg = undefined; // FFMpeg object class // Internal data only for this class #liveSessions = new Map(); // Track active HomeKit live stream sessions (port, crypto, rtpSplitter) #recordingConfig = {}; // HomeKit Secure Video recording configuration #cameraImages = {}; // Snapshot resource images #motionCooldownActive = false; // Flag to track if motion cooldown is active constructor(accessory, api, log, deviceData) { super(accessory, api, log, deviceData); // Load support image files as required const loadImageResource = (filename, label) => { let buffer = undefined; let file = path.resolve(__dirname, RESOURCE_PATH, filename); if (fs.existsSync(file) === true) { buffer = fs.readFileSync(file); } else { this.log?.warn?.('Failed to load %s image resource for "%s"', label, this.deviceData.description); } return buffer; }; this.#cameraImages = { offline: loadImageResource(RESOURCE_IMAGES.CAMERA_OFFLINE, 'offline'), off: loadImageResource(RESOURCE_IMAGES.CAMERA_OFF, 'video off'), transfer: loadImageResource(RESOURCE_IMAGES.CAMERA_TRANSFER, 'transferring'), }; // Create ffmpeg object if have been told valid binary if (typeof this.deviceData?.ffmpeg?.binary === 'string' && this.deviceData?.ffmpeg?.valid === true) { this.ffmpeg = new FFmpeg(this.deviceData?.ffmpeg?.binary); } } // Class functions onAdd() { // Pre-0.4.0 cleanup // Remove legacy extra motion services so only the primary motion service remains this.accessory.services .filter((service) => service.UUID === this.hap.Service.MotionSensor.UUID) .forEach((service) => { if (service.subtype !== 1) { this?.log?.debug?.( 'Removing legacy extra motion service from "%s" with id of "%s"', this.deviceData.description, service.subtype, ); this.accessory.removeService(service); } }); // Remove legacy CameraOperatingMode service which was created when HKSV was disabled if (this.accessory?.context?.hksv === false) { this.accessory.services .filter((service) => service.UUID === this.hap.Service.CameraOperatingMode.UUID) .forEach((service) => { this?.log?.debug?.('Removing legacy CameraOperatingMode service from "%s"', this.deviceData.description); this.accessory.removeService(service); }); } // End of pre-0.4.0 cleanup // Setup the motion service if not already present on the accessory, and link it to the Eve app if configured to do so // We also need to add the active characteristic to the motion service for it to work with HKSV // This needs to be done before we setup the HomeKit camera controller this.motionService = this.addHKService(this.hap.Service.MotionSensor, '', 1, {}); this.addHKCharacteristic(this.motionService, this.hap.Characteristic.Active, {}); // Required for HKSV this.motionService.updateCharacteristic(this.hap.Characteristic.Active, true); // Required for HKSV this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially // Setup HomeKit camera controller // Doorbell device, we'll setup a doorbell controller instead of a standard camera controller // Otherwise we'll setup as a standard camera controller if (this.deviceData.type === DEVICE_TYPE.DOORBELL) { this.controller = new this.hap.DoorbellController(this.generateControllerOptions()); } else { this.controller = new this.hap.CameraController(this.generateControllerOptions()); } if (this.controller !== undefined) { // Configure the controller that's been created this.accessory.configureController(this.controller); } // Setup battery service if required and not already present on the accessory if (this.deviceData.model.includes('battery') === true && Number.isFinite(Number(this.deviceData?.battery_level)) === true) { this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1); this.batteryService.setHiddenService(true); } // Setup set characteristics if (this.controller?.recordingManagement?.operatingModeService !== undefined && this.deviceData?.has_statusled === true) { this.addHKCharacteristic( this.controller.recordingManagement.operatingModeService, 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_device_uuid, statusled_brightness: value === true ? 0 : 1, }); this?.log?.info?.('Recording status LED on "%s" was turned %s', this.deviceData.description, value === true ? 'on' : 'off'); } this.controller.recordingManagement.operatingModeService.updateCharacteristic( this.hap.Characteristic.CameraOperatingModeIndicator, value, ); }, onGet: () => { return this.deviceData.statusled_brightness !== 1; }, }, ); } if (this.controller?.recordingManagement?.operatingModeService !== undefined && this.deviceData?.has_irled === true) { this.addHKCharacteristic(this.controller.recordingManagement.operatingModeService, 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_device_uuid, irled_enabled: value === true ? 'auto_on' : 'always_off', }); this?.log?.info?.('Night vision on "%s" was turned %s', this.deviceData.description, value === true ? 'on' : 'off'); } this.controller.recordingManagement.operatingModeService.updateCharacteristic(this.hap.Characteristic.NightVision, value); }, onGet: () => { return this.deviceData.irled_enabled; }, }); } if (this.controller?.recordingManagement?.operatingModeService !== undefined) { this.addHKCharacteristic(this.controller.recordingManagement.operatingModeService, this.hap.Characteristic.HomeKitCameraActive, { onSet: (value) => { 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({ uuid: this.deviceData.nest_google_device_uuid, streaming_enabled: value === this.hap.Characteristic.HomeKitCameraActive.ON ? true : false, }); this?.log?.info?.( 'Camera on "%s" was turned %s', this.deviceData.description, value === this.hap.Characteristic.HomeKitCameraActive.ON ? 'on' : 'off', ); } this.controller.recordingManagement.operatingModeService.updateCharacteristic(this.hap.Characteristic.HomeKitCameraActive, value); }, }); } if (this.controller?.recordingManagement?.operatingModeService !== undefined && this.deviceData?.has_video_flip === true) { this.addHKCharacteristic(this.controller.recordingManagement.operatingModeService, this.hap.Characteristic.ImageRotation, { onGet: () => { return this.deviceData.video_flipped === true ? 180 : 0; }, }); } if (this.controller?.recordingManagement?.recordingManagementService !== undefined && this.deviceData.has_microphone === true) { this.addHKCharacteristic( this.controller.recordingManagement.recordingManagementService, 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_device_uuid, audio_enabled: value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false, }); this?.log?.info?.( 'Audio recording on "%s" was turned %s', this.deviceData.description, value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? 'on' : 'off', ); } this.controller.recordingManagement.recordingManagementService.updateCharacteristic( this.hap.Characteristic.RecordingAudioActive, value, ); }, 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?.('Migration between Nest <-> Google Home apps is underway for "%s"', this.deviceData.description); } this.deviceData?.streaming_protocols?.some?.((protocol) => STREAMERS[protocol]?.module !== undefined) !== true && this?.log?.error?.( 'No suitable streaming protocol is present for "%s". Streaming and recording will be unavailable', this.deviceData.description, ); // Extra setup details for output this.deviceData?.streaming_protocols?.forEach?.((protocol) => { STREAMERS[protocol]?.module !== undefined && this.postSetupDetail(STREAMERS[protocol].label, LOG_LEVELS.DEBUG); }); (this.deviceData?.has_motion_detection !== true || this.deviceData?.motion_detection_enabled !== true) && this.postSetupDetail('Camera motion detection not available', LOG_LEVELS.WARN); this.postSetupDetail( 'HomeKit Secure Video support' + (this.deviceData.model.includes('battery') === true && Number.isFinite(Number(this.deviceData?.battery_level)) === true ? ' (on battery)' : ''), ); this.deviceData.ffmpeg.hwaccel === true && this.postSetupDetail('Hardware video acceleration'); this.deviceData.ffmpeg.transcode === true && this.postSetupDetail('Video transcoding'); // Setup repeat polling for camera events. // This is time-sensitive and we want to trigger HomeKit automations as close to real-time as possible this.addTimer(TIMERS.CAMERA_EVENTS.name, { interval: TIMERS.CAMERA_EVENTS.interval }); } async onRemove() { // Clean up our camera object since this device is being removed // Stop all streamer logic (buffering, output, etc) await this.streamer?.stopEverything?.(); // Terminate all live sessions and ffmpeg processes for this camera/doorbell for (let sessionID of [...this.#liveSessions.keys()]) { this.#cleanupLiveSession(sessionID); } // Cleanup any recording sessions and wake HKSV generators this.#cleanupRecordingSession(); // Ensure all ffmpeg processes are removed this.ffmpeg?.killAllSessions?.(this.uuid); // Remove any motion services we created if (this.motionService !== undefined) { this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); this.accessory.removeService(this.motionService); } // Remove the camera controller if (this.controller !== undefined) { this.accessory.removeController(this.controller); } // Clear references this.motionService = undefined; this.batteryService = undefined; this.streamer = undefined; this.controller = undefined; this.ffmpeg = undefined; } async onUpdate(deviceData) { if (typeof deviceData !== 'object' || deviceData?.constructor !== Object || this.controller === undefined) { return; } // Update battery status if (this.batteryService !== undefined) { this.batteryService.updateCharacteristic(this.hap.Characteristic.BatteryLevel, deviceData.battery_level); this.batteryService.updateCharacteristic( this.hap.Characteristic.StatusLowBattery, deviceData.battery_level > LOW_BATTERY_LEVEL ? this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW, ); this.batteryService.updateCharacteristic( this.hap.Characteristic.ChargingState, (deviceData.battery_level > this.deviceData.battery_level && this.deviceData.battery_level !== 0 ? true : false) ? this.hap.Characteristic.ChargingState.CHARGING : this.hap.Characteristic.ChargingState.NOT_CHARGING, ); } if (this.deviceData.migrating !== true && deviceData.migrating === true) { // Migration happening between Nest <-> Google Home apps. We'll stop any active streams, close the current streaming object this?.log?.warn?.('Migration between Nest <-> Google Home apps has started for "%s"', deviceData.description); this.streamer?.stopEverything?.(); this.streamer = undefined; } if (this.deviceData.migrating === true && deviceData.migrating !== true) { // Migration has completed between Nest <-> Google Home apps 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 && JSON.stringify(deviceData.streaming_protocols) !== JSON.stringify(this.deviceData.streaming_protocols) ) { this?.log?.warn?.('Available streaming protocols have changed for "%s"', deviceData.description); this.streamer.stopEverything(); this.streamer = undefined; } if (this.streamer === undefined && deviceData.migrating !== true) { if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.WEBRTC) === true && WebRTC !== undefined) { if (this.deviceData.migrating === true && deviceData.migrating !== true) { this?.log?.debug?.('Using WebRTC streamer for "%s" after migration', deviceData.description); } this.streamer = new WebRTC(this.uuid, deviceData, { log: this.log, supportDump: this.deviceData.supportDump === true, bufferDuration: PREBUFFER_LENGTH * 2, }); } if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.NEXUSTALK) === true && NexusTalk !== undefined) { if (this.deviceData.migrating === true && deviceData.migrating !== true) { this?.log?.debug?.('Using NexusTalk streamer for "%s" after migration', deviceData.description); } this.streamer = new NexusTalk(this.uuid, deviceData, { log: this.log, supportDump: this.deviceData.supportDump === true, bufferDuration: PREBUFFER_LENGTH * 2, }); } if ( this?.streamer?.isBuffering() === false && this?.controller?.recordingManagement?.recordingManagementService !== undefined && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === this.hap.Characteristic.Active.ACTIVE ) { await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_BUFFER, { options: {}, }); } } // Handle motion detection on camera changes if ( (deviceData.has_motion_detection === true && this.deviceData.has_motion_detection !== true) || (deviceData.motion_detection_enabled === true && this.deviceData.motion_detection_enabled !== true) ) { this?.log?.info?.('Camera motion detection has been enabled on "%s"', deviceData.description); } if ( (deviceData.has_motion_detection !== true && this.deviceData.has_motion_detection === true) || (deviceData.motion_detection_enabled !== true && this.deviceData.motion_detection_enabled === true) ) { this?.log?.warn?.('Camera motion detection has been disabled on "%s"', deviceData.description); // Clear any existing motion status in HomeKit since detection is now disabled if (this.motionService !== undefined && this.motionService.getCharacteristic(this.hap.Characteristic.MotionDetected).value === true) { this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); this.#motionCooldownActive = false; this.removeTimer(TIMERS.MOTION_COOLDOWN.name); this.history(this.motionService, { status: 0 }); } } if (this.controller?.recordingManagement?.operatingModeService !== undefined) { // Update camera off/on status this.controller.recordingManagement.operatingModeService.updateCharacteristic( this.hap.Characteristic.ManuallyDisabled, deviceData.streaming_enabled === true ? this.hap.Characteristic.ManuallyDisabled.ENABLED : this.hap.Characteristic.ManuallyDisabled.DISABLED, ); 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.controller.recordingManagement.operatingModeService.updateCharacteristic( this.hap.Characteristic.CameraOperatingModeIndicator, deviceData.statusled_brightness !== 1, ); } if (deviceData?.has_irled === true) { // Set nightvision status in HomeKit this.controller.recordingManagement.operatingModeService.updateCharacteristic( this.hap.Characteristic.NightVision, deviceData.irled_enabled === true, ); } if (deviceData?.has_video_flip === true) { // Update image flip status this.controller.recordingManagement.operatingModeService.updateCharacteristic( this.hap.Characteristic.ImageRotation, deviceData.video_flipped === true ? 180 : 0, ); } } if (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.Volume, deviceData.xxx); // if audio is disabled, we'll mute microphone this.controller.setMicrophoneMuted(deviceData.audio_enabled === false ? true : false); } if (this.controller?.speakerService !== undefined) { // Update speaker volume if specified if (deviceData.audio_enabled === true && Number.isFinite(Number(deviceData.speaker_volume)) === true) { this.controller.speakerService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.speaker_volume); } // if audio is disabled, we'll mute speaker this.controller.setSpeakerMuted(deviceData.audio_enabled === false ? true : false); } // Process camera events, the most recent event is first for (const event of deviceData.events || []) { if ( this.controller?.recordingManagement?.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 // We will use this to trigger the starting of the HKSV recording if the camera is active if (event.types.includes('motion') === true) { // Only set motion to true on first detection if (this.#motionCooldownActive === false) { if (typeof this.motionService === 'object') { if (this.deviceData?.logMotionEvents === true) { this?.log?.info?.('Motion detected in "%s"', deviceData.description); } // Trigger motion this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, true); // Log motion started into history this.history(this.motionService, { status: 1, }); } this.#motionCooldownActive = true; } // Start or extend motion cooldown timer (reset: true extends existing timer) this.addTimer(TIMERS.MOTION_COOLDOWN.name, { delay: this.deviceData.motionCooldown * 1000, reset: true, }); } } } } async onShutdown() { // Clear any motion if (this.motionService !== undefined) { this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); } // Stop all streamer logic (buffering, output, etc) await this.streamer?.stopEverything?.(); // Terminate all live sessions and ffmpeg processes for this camera/doorbell for (let sessionID of [...this.#liveSessions.keys()]) { this.#cleanupLiveSession(sessionID); } // Cleanup any recording sessions and wake HKSV generators this.#cleanupRecordingSession(); // Ensure all ffmpeg processes are removed this.ffmpeg?.killAllSessions?.(this.uuid); } async onTimer(message) { if (typeof message !== 'object' || typeof message?.timer !== 'string') { return; } // Handle camera events polling timer if (message.timer === TIMERS.CAMERA_EVENTS.name) { let response = await this.get({ uuid: this.deviceData.nest_google_device_uuid, camera_events: true }); // // Send updates (empty array if no events) this.update({ events: Array.isArray(response?.camera_events) === true ? response.camera_events : [], }); } // Handle motion cooldown timer expiration if (message.timer === TIMERS.MOTION_COOLDOWN.name) { // Motion cooldown expired if (typeof this.motionService === 'object') { // Mark motion service as motion not detected this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // Log motion stopped into history this.history(this.motionService, { status: 0 }); } this.#motionCooldownActive = false; } } async onMessage(type, message) { if (typeof type !== 'string' || type === '' || (message !== undefined && (typeof message !== 'object' || message === null))) { return; } if (type === HomeKitDevice.ONLINE) { // Notified that camera is back online if (this.motionService !== undefined) { // Re-enable motion service in HomeKit now camera is reachable again this.motionService.updateCharacteristic(this.hap.Characteristic.StatusActive, true); } // If HKSV recording is enabled, ensure prebuffering is running again. // This allows us to capture footage leading up to any motion event. if ( this?.streamer?.isBuffering?.() === false && this?.controller?.recordingManagement?.recordingManagementService !== undefined && this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value === this.hap.Characteristic.Active.ACTIVE ) { // Restart buffer pipeline so camera is ready for motion-triggered recording await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_BUFFER, { options: {}, }); } return; } if (type === HomeKitDevice.OFFLINE) { // Notified that camera is offline if (this.motionService !== undefined) { // Clear any existing motion status in HomeKit since camera is now offline // This will also naturally stop any inflight motion-triggered recording this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); this.motionService.updateCharacteristic(this.hap.Characteristic.StatusActive, false); this.#motionCooldownActive = false; this.removeTimer(TIMERS.MOTION_COOLDOWN.name); this.history(this.motionService, { status: 0 }); } // Stop any prebuffering/record buffering await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_BUFFER, {}); return; } } // Taken and adapted from: // https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts async *handleRecordingStreamRequest(sessionID) { if (this.ffmpeg instanceof FFmpeg === false) { // No valid ffmpeg binary present, so cannot do recording!! 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.streamer === undefined) { this?.log?.error?.( 'Received request to start recording for "%s" however we do not have any associated streaming protocol support', this.deviceData.description, ); return; } if (this.motionService !== undefined && this.motionService.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 occurring this?.log?.debug?.( 'Received request to commence recording for "%s" however we have not detected any motion', this.deviceData.description, ); return; } if (this.#recordingConfig?.videoCodec === undefined) { this?.log?.debug?.( 'Received request to start recording for "%s" before HKSV recording configuration was available', this.deviceData.description, ); return; } let includeAudio = this.deviceData.audio_enabled === true && this.controller?.recordingManagement?.recordingManagementService?.getCharacteristic(this.hap.Characteristic.RecordingAudioActive) ?.value === this.hap.Characteristic.RecordingAudioActive.ENABLE; let commandLine = [ '-hide_banner', '-nostats', '-fflags', '+discardcorrupt+genpts', '-avoid_negative_ts', 'make_zero', '-max_delay', '500000', '-flags', 'low_delay', // Video input '-f', 'h264', '-i', 'pipe:0', // Audio input (optional) ...(includeAudio === true ? this.streamer.codecs.audio === Streamer.CODEC_TYPE.PCM ? ['-thread_queue_size', '1024', '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', 'pipe:3'] : this.streamer.codecs.audio === Streamer.CODEC_TYPE.AAC ? ['-thread_queue_size', '1024', '-f', 'aac', '-i', 'pipe:3'] : [] : []), // Video output including hardware acceleration if available '-map', '0:v:0', '-codec:v', this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec !== undefined ? this.ffmpeg.hardwareH264Codec : 'libx264', '-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', ...(this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec === 'h264_videotoolbox' ? ['-realtime', 'true'] : []), ...(this.deviceData?.ffmpeg?.hwaccel !== true || ['libx264', 'h264_nvenc', 'h264_qsv'].includes(this.ffmpeg?.hardwareH264Codec || '') === true ? ['-preset', 'veryfast', '-bf', '0'] : []), ...(this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec === 'h264_nvenc' ? ['-tune', 'll', '-zerolatency', '1'] : []), '-filter:v', 'fps=fps=' + this.#recordingConfig.videoCodec.resolution[2] + ',format=yuv420p', '-fps_mode', 'cfr', '-g:v', Math.max( 1, Math.round((this.#recordingConfig.videoCodec.resolution[2] * this.#recordingConfig.videoCodec.parameters.iFrameInterval) / 1000), ).toString(), '-b:v', Math.round(this.#recordingConfig.videoCodec.parameters.bitRate * 2) + 'k', '-maxrate', Math.round(this.#recordingConfig.videoCodec.parameters.bitRate * 2) + 'k', '-bufsize', Math.round(this.#recordingConfig.videoCodec.parameters.bitRate * 4) + 'k', '-video_track_timescale', '90000', '-movflags', 'frag_keyframe+empty_moov+default_base_moof', // Audio output ...(includeAudio === true ? [ '-map', '1:a:0', '-codec:a', 'libfdk_aac', '-profile:a', 'aac_low', '-ar', '16000', '-b:a', '16k', '-ac', '1', '-filter:a', 'aresample=async=1:first_pts=0', ] : []), '-f', 'mp4', 'pipe:1', ]; // Start our ffmpeg recording process and stream from our streamer // video is pipe #1 // audio is pipe #3 if including audio this?.log?.debug?.( 'ffmpeg process for recording stream from "%s" will be called using the following commandline: %s', this.deviceData.description, commandLine.join(' '), ); let ffmpegStream = this.ffmpeg.createSession( this.uuid, sessionID, commandLine, 'record', this.deviceData.ffmpeg.debug === true ? (data) => { if (data.toString().includes('frame=') === false) { this?.log?.debug?.(data.toString()); } } : undefined, 4, // 4 pipes required ); if (ffmpegStream === undefined) { return; } let buffer = Buffer.alloc(0); let mp4boxes = []; ffmpegStream?.stdout?.on?.('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); while (buffer.length >= 8) { let boxSize = buffer.readUInt32BE(0); if (boxSize < 8 || buffer.length < boxSize) { // We don't have enough data in the buffer yet to process the full mp4 box // so, exit loop and await more data break; } let boxType = buffer.subarray(4, 8).toString(); // Add it to our queue to be pushed out through the generator function. mp4boxes.push({ header: buffer.subarray(0, 8), type: boxType, data: buffer.subarray(8, boxSize), }); buffer = buffer.subarray(boxSize); this.emit(MP4BOX); } }); ffmpegStream?.process?.on?.('exit', async (code, signal) => { this.emit(MP4BOX); if (signal === 'SIGKILL' || (signal === null && code === 0)) { return; } this?.log?.error?.('ffmpeg recording process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code); try { await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_RECORD, { sessionID: sessionID, }); } catch { // Ignore errors if streamer already stopped } this.#cleanupRecordingSession(sessionID); }); // Start the appropriate streamer let { video, audio } = await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_RECORD, { sessionID: sessionID, options: { includeAudio: includeAudio === true, recordTime: Date.now() - PREBUFFER_LENGTH, // Start a few seconds in the past to try to capture pre-roll video before motion trigger }, }); // Connect the ffmpeg process to the streamer input/output video?.pipe?.(ffmpegStream?.stdin); // Streamer video -> ffmpeg stdin (pipe:0) audio?.pipe?.(ffmpegStream?.stdio?.[3]); // Streamer audio (if present) -> ffmpeg pipe:3 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.ffmpeg?.hasSession?.(this.uuid, sessionID, 'record') === false) { break; } if (mp4boxes.length === 0) { await EventEmitter.once(this, MP4BOX); if (this.ffmpeg?.hasSession?.(this.uuid, sessionID, 'record') === false) { break; } if (mp4boxes.length === 0) { continue; } } let box = mp4boxes.shift(); if (box === undefined) { continue; } segment.push(box.header, box.data); if (box.type === 'moov' || box.type === 'mdat') { yield { data: Buffer.concat(segment), isLast: false }; segment = []; } } } async closeRecordingStream(sessionID, closeReason) { await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_RECORD, { sessionID: sessionID, }); this.#cleanupRecordingSession(sessionID); if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) { this?.log?.info?.('Completed recording from "%s"', this.deviceData.description); } else { this?.log?.warn?.( 'Recording from "%s" completed with error. Reason was "%s"', this.deviceData.description, this.hap.HDSProtocolSpecificErrorReason?.[closeReason] || 'code ' + closeReason, ); } } async updateRecordingActive(enableRecording) { if (this.streamer === undefined) { return; } 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?.('Recording was turned on for "%s"', this.deviceData.description); await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_BUFFER, { options: {}, }); } if (enableRecording === false && this.streamer.isBuffering() === true) { // Stop buffering stream for this camera/doorbell await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_BUFFER); 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 // eslint-disable-next-line no-unused-vars const isLikelyBlackImage = (buffer) => { // TODO <- Placeholder for actual black image detection logic return false; }; // Get current image from camera/doorbell let imageBuffer = undefined; if (this.deviceData.migrating !== true && this.deviceData.streaming_enabled === true && this.deviceData.online === true) { // Request a snapshot image from upstream. // Snapshot freshness, caching, and fallback handling are managed centrally in system.js let response = await this.get({ uuid: this.deviceData.nest_google_device_uuid, camera_snapshot: Buffer.alloc(0) }); if ( Buffer.isBuffer(response?.camera_snapshot) === true && response.camera_snapshot.length > 0 && isLikelyBlackImage(response.camera_snapshot) === false ) { imageBuffer = response.camera_snapshot; } } if ( this.deviceData.migrating !== true && this.deviceData.streaming_enabled === false && this.deviceData.online === true && this.#cameraImages?.off !== undefined ) { // Return 'camera switched off' jpg to image buffer imageBuffer = this.#cameraImages.off; } if (this.deviceData.migrating !== true && this.deviceData.online === false && this.#cameraImages?.offline !== undefined) { // Return 'camera offline' jpg to image buffer imageBuffer = this.#cameraImages.offline; } if (this.deviceData.migrating === true && this.#cameraImages?.transfer !== undefined) { // Return 'camera transferring' jpg to image buffer imageBuffer = this.#cameraImages.transfer; } callback( Buffer.isBuffer(imageBuffer) === true && imageBuffer.length > 0 ? null : new Error('Unable to obtain Camera/Doorbell snapshot'), imageBuffer, ); } async prepareStream(request, callback) { // HomeKit has asked us to prepare ports and encryption details for video/audio streaming const getPort = async () => { return new Promise((resolve, reject) => { let server = dgram.createSocket('udp4'); server.bind({ port: 0, exclusive: true }, () => { let port = server.address().port; server.close(() => resolve(port)); }); server.on('error', reject); }); }; // 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(), rtpSplitterPort: await getPort(), audioCryptoSuite: request.audio.srtpCryptoSuite, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), audioSSRC: this.hap.CameraController.generateSynchronisationSource(), rtpSplitter: null, // setup later during stream start }; // Converts ipv4-mapped ipv6 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.rtpSplitterPort, ssrc: sessionInfo.audioSSRC, srtp_key: request.audio.srtp_key, srtp_salt: request.audio.srtp_salt, }, }; this.#liveSessions.set(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) { if (this.streamer === undefined) { // We have no streamer object configured, so cannot do live streams!! this?.log?.error?.( 'Received request to start live video for "%s" however we do not have any associated streaming protocol support', this.deviceData.description, ); return callback?.(); } if (this.ffmpeg instanceof FFmpeg === false) { // No valid ffmpeg binary present, so cannot do live streams!! this?.log?.warn?.( 'Received request to start live video for "%s" however we do not have a valid ffmpeg binary', this.deviceData.description, ); return callback?.(); } let session = this.#liveSessions.get(request.sessionID); let includeAudio = this.deviceData.audio_enabled === true && this.streamer?.codecs?.audio !== undefined; // Start the live streamer process let { video, audio, talkback } = await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_LIVE, { sessionID: request.sessionID, options: { includeAudio: includeAudio === true, waitForReady: 2000, // Timeout to wait for source_ready before starting ffmpeg process }, }); // Build our ffmpeg command string for the liveview video/audio stream let commandLine = [ '-hide_banner', '-nostats', // Raw stream copy mode benefits from wallclock timestamps for stable live audio pacing ...(this.deviceData?.ffmpeg?.transcode === true ? [] : ['-use_wallclock_as_timestamps', '1']), '-fflags', '+discardcorrupt+genpts', '-avoid_negative_ts', 'make_zero', '-max_delay', '500000', '-flags', 'low_delay', // Video input '-f', 'h264', '-i', 'pipe:0', // Audio input (if enabled) ...(includeAudio === true ? this.streamer.codecs.audio === Streamer.CODEC_TYPE.PCM ? ['-thread_queue_size', '1024', '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', 'pipe:3'] : this.streamer.codecs.audio === Streamer.CODEC_TYPE.AAC ? ['-thread_queue_size', '1024', '-f', 'aac', '-i', 'pipe:3'] : [] : []), // Video output '-map', '0:v:0', ...(this.deviceData?.ffmpeg?.transcode === true ? [ '-codec:v', this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec !== undefined ? this.ffmpeg.hardwareH264Codec : 'libx264', '-profile:v', request.video.profile === this.hap.H264Profile.HIGH ? 'high' : request.video.profile === this.hap.H264Profile.MAIN ? 'main' : 'baseline', '-level:v', request.video.level === this.hap.H264Level.LEVEL4_0 ? '4.0' : request.video.level === this.hap.H264Level.LEVEL3_2 ? '3.2' : '3.1', ...(this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec === 'h264_videotoolbox' ? ['-realtime', 'true'] : []), ...(this.deviceData?.ffmpeg?.hwaccel !== true || ['libx264', 'h264_nvenc', 'h264_qsv'].includes(this.ffmpeg?.hardwareH264Codec || '') === true ? ['-preset', 'veryfast', '-bf', '0'] : []), ...(this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec === 'h264_nvenc' ? ['-tune', 'll', '-zerolatency', '1'] : []), '-filter:v', 'format=yuv420p', '-fps_mode', 'passthrough', '-g:v', '15', '-b:v', Math.round(request.video.max_bit_rate * 1.25) + 'k', '-maxrate', Math.round(request.video.max_bit_rate * 1.25) + 'k', '-bufsize', Math.round(request.video.max_bit_rate * 2.5) + 'k', ] : ['-codec:v', 'copy', '-fps_mode', 'passthrough']), '-video_track_timescale', '90000', '-payload_type', request.video.pt, '-ssrc', session.videoSSRC, '-f', 'rtp', '-srtp_out_suite', this.hap.SRTPCryptoSuites[session.videoCryptoSuite], '-srtp_out_params', session.videoSRTP.toString('base64'), 'srtp://' + session.address + ':' + session.videoPort + '?rtcpport=' + session.videoPort + '&pkt_size=' + request.video.mtu, // Audio output (if enabled) ...(includeAudio === true ? request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD ? ['-map', '1:a:0', '-codec:a', 'libfdk_aac', '-profile:a', 'aac_eld', '-filter:a', 'aresample=async=1:first_pts=0'] : request.audio.codec === this.hap.AudioStreamingCodecType.OPUS ? [ '-map', '1:a:0', '-codec:a', 'libopus', '-application', 'lowdelay', '-frame_duration', request.audio.packet_time.toString(), '-filter:a', 'aresample=async=1:first_pts=0', ] : [] : []), // Shared audio output params ...(includeAudio === true ? [ '-flags', '+global_header', '-ar', request.audio.sample_rate.toString() +