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
JavaScript
// 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() +