UNPKG

homebridge-eufy-security

Version:
208 lines 9.03 kB
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