homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
208 lines • 9.03 kB
JavaScript
import { PropertyName } from 'eufy-security-client';
import { FFmpeg, FFmpegParameters } from '../utils/ffmpeg.js';
import { CHAR, SERV, isRtspReady, log } from '../utils/utils.js';
const MAX_RECORDING_MINUTES = 1; // should never be used
const HKSVQuitReason = [
'Normal',
'Not allowed',
'Busy',
'Cancelled',
'Unsupported',
'Unexpected Failure',
'Timeout',
'Bad data',
'Protocol error',
'Invalid Configuration',
];
export class RecordingDelegate {
platform;
accessory;
camera;
cameraConfig;
localLivestreamManager;
snapshotDlg;
configuration;
forceStopTimeout;
closeReason;
handlingStreamingRequest = false;
controller;
session;
/** Delay before extracting a snapshot from a running HKSV recording (ms). */
static RECORDING_SNAPSHOT_DELAY_MS = 2_000;
constructor(platform, accessory, camera, cameraConfig, localLivestreamManager, snapshotDlg) {
this.platform = platform;
this.accessory = accessory;
this.camera = camera;
this.cameraConfig = cameraConfig;
this.localLivestreamManager = localLivestreamManager;
this.snapshotDlg = snapshotDlg;
}
setController(controller) {
this.controller = controller;
}
isRecording() {
return this.handlingStreamingRequest;
}
resetMotionSensor() {
const motionDetected = this.accessory
.getService(SERV.MotionSensor)?.getCharacteristic(CHAR.MotionDetected).value;
if (motionDetected) {
this.accessory
.getService(SERV.MotionSensor)?.getCharacteristic(CHAR.MotionDetected)
.updateValue(false);
}
}
clearForceStopTimeout() {
if (this.forceStopTimeout) {
clearTimeout(this.forceStopTimeout);
this.forceStopTimeout = undefined;
}
}
isMotionDetected() {
return !!this.accessory
.getService(SERV.MotionSensor)?.getCharacteristic(CHAR.MotionDetected).value;
}
async configureInputSource(videoParams, audioParams) {
if (isRtspReady(this.camera, this.cameraConfig)) {
const url = this.camera.getPropertyValue(PropertyName.DeviceRTSPStreamUrl);
log.debug(this.camera.getName(), 'RTSP URL: ' + url);
videoParams.setInputSource(url);
audioParams.setInputSource(url);
}
else {
const streamData = await this.localLivestreamManager.getLocalLiveStream();
await videoParams.setInputStream(streamData.videostream);
await audioParams.setInputStream(streamData.audiostream);
}
}
async *handleRecordingStreamRequest() {
this.handlingStreamingRequest = true;
this.closeReason = undefined;
log.info(this.camera.getName(), 'requesting recording for HomeKit Secure Video.');
try {
if (!this.configuration) {
log.error(this.camera.getName(), 'No recording configuration available. Aborting.');
yield { data: Buffer.alloc(0), isLast: true };
return;
}
const audioEnabled = this.controller?.recordingManagement?.recordingManagementService.getCharacteristic(CHAR.RecordingAudioActive).value;
log.debug(this.camera.getName(), `HKSV audio recording: ${audioEnabled ? 'enabled' : 'disabled'}.`);
const videoParams = await FFmpegParameters.forVideoRecording();
const audioParams = await FFmpegParameters.forAudioRecording();
const videoConfig = this.cameraConfig.videoConfig ?? {};
videoParams.setupForRecording(videoConfig, this.configuration);
audioParams.setupForRecording(videoConfig, this.configuration);
await this.configureInputSource(videoParams, audioParams);
// Opportunistically capture a snapshot from the HKSV recording stream
setTimeout(() => {
this.snapshotDlg.captureSnapshotFromActiveLivestream().catch((error) => {
log.debug(this.camera.getName(), 'Snapshot capture from HKSV recording failed: ' + error);
});
}, RecordingDelegate.RECORDING_SNAPSHOT_DELAY_MS);
const ffmpeg = new FFmpeg(`[${this.camera.getName()}] [HSV Recording Process]`, audioEnabled ? [videoParams, audioParams] : videoParams);
this.session = await ffmpeg.startFragmentedMP4Session();
const maxDuration = Math.min(this.cameraConfig.hsvRecordingDuration ?? MAX_RECORDING_MINUTES * 60, this.platform.config.CameraMaxLivestreamDuration);
if (maxDuration > 0) {
this.forceStopTimeout = setTimeout(() => {
log.warn(this.camera.getName(), `Recording force-stopped after ${maxDuration}s.`);
this.resetMotionSensor();
}, maxDuration * 1000);
}
yield* this.generateFragments(this.session.generator);
}
catch (error) {
if (!this.handlingStreamingRequest && this.closeReason && this.closeReason === 3 /* HDSProtocolSpecificErrorReason.CANCELLED */) {
log.debug(this.camera.getName(), 'Recording encountered an error but that is expected, as the recording was canceled beforehand. Error: ' + error);
}
else {
log.error(this.camera.getName(), 'Error while recording: ' + error);
}
}
finally {
this.logCloseReason();
this.clearForceStopTimeout();
this.resetMotionSensor();
this.localLivestreamManager.stopLocalLiveStream();
}
}
logCloseReason() {
if (!this.closeReason) {
return;
}
if (this.closeReason === 3 /* HDSProtocolSpecificErrorReason.CANCELLED */) {
log.debug(this.camera.getName(), 'The recording process was canceled by the HomeKit Controller.');
}
else if (this.closeReason !== 0 /* HDSProtocolSpecificErrorReason.NORMAL */) {
log.warn(this.camera.getName(), `The recording process was aborted by HSV with reason "${HKSVQuitReason[this.closeReason]}"`);
}
}
/**
* Assembles fragmented MP4 boxes into HKSV-compatible recording packets.
* Yields an initialization segment (ftyp+moov), then paired moof+mdat fragments.
*/
async *generateFragments(generator) {
const cameraName = this.camera.getName();
let initPending = [];
let moofBuffer = null;
let isInit = true;
let fragmentCount = 0;
for await (const { header, type, data } of generator) {
if (!this.handlingStreamingRequest) {
log.debug(cameraName, 'Recording was ended prematurely.');
break;
}
if (isInit) {
initPending.push(header, data);
if (type === 'moov') {
const fragment = Buffer.concat(initPending);
initPending = [];
isInit = false;
log.debug(cameraName, `HKSV: Sending initialization segment, size: ${fragment.length}`);
yield { data: fragment, isLast: false };
}
continue;
}
if (type === 'moof') {
moofBuffer = Buffer.concat([header, data]);
}
else if (type === 'mdat' && moofBuffer) {
const fragment = Buffer.concat([moofBuffer, header, data]);
moofBuffer = null;
fragmentCount++;
log.debug(cameraName, `HKSV: Fragment #${fragmentCount}, size: ${fragment.length}`);
yield { data: fragment, isLast: false };
if (!this.isMotionDetected()) {
log.debug(cameraName, 'Ending recording session due to motion stopped.');
break;
}
}
}
}
updateRecordingActive(active) {
log.debug(`Recording: ${active}`, this.accessory.displayName);
}
updateRecordingConfiguration(configuration) {
this.configuration = configuration;
}
closeRecordingStream(streamId, reason) {
log.info(this.camera.getName(), 'Closing recording process');
if (this.session) {
log.debug(this.camera.getName(), 'Stopping recording session.');
this.session.socket?.destroy();
this.session.process?.kill('SIGKILL');
this.session = undefined;
}
else {
log.warn('Recording session could not be closed gracefully.');
}
this.clearForceStopTimeout();
this.resetMotionSensor();
this.closeReason = reason;
this.handlingStreamingRequest = false;
}
acknowledgeStream(streamId) {
log.debug('end of recording acknowledged!');
this.closeRecordingStream(streamId, undefined);
}
}
//# sourceMappingURL=recordingDelegate.js.map