UNPKG

homebridge-eufy-security

Version:
345 lines 15.7 kB
import { createSocket } from 'dgram'; import { pickPort } from 'pick-port'; import { PropertyName } from 'eufy-security-client'; import { FFmpeg, FFmpegParameters } from '../utils/ffmpeg.js'; import { TalkbackStream } from '../utils/Talkback.js'; import { HAP, isRtspReady } from '../utils/utils.js'; import { LocalLivestreamManager } from './LocalLivestreamManager.js'; import { snapshotDelegate } from './snapshotDelegate.js'; export class StreamingDelegate { camera; controller; log; localLivestreamManager; snapshotDelegate; // keep track of sessions pendingSessions = new Map(); ongoingSessions = new Map(); get device() { return this.camera.device; } get videoConfig() { return this.camera.cameraConfig.videoConfig; } constructor(camera) { this.camera = camera; this.log = camera.log; this.localLivestreamManager = new LocalLivestreamManager(camera); this.snapshotDelegate = new snapshotDelegate(this.camera, this.localLivestreamManager); } setController(controller) { this.controller = controller; } getLivestreamManager() { return this.localLivestreamManager; } getSnapshotDelegate() { return this.snapshotDelegate; } async handleSnapshotRequest(request, callback) { this.log.debug(`Snapshot requested: ${request.width}x${request.height}`); try { const snapshot = await this.snapshotDelegate.getSnapshotBufferResized(request); this.log.debug('Snapshot byte length: ' + snapshot?.byteLength); callback(undefined, snapshot); } catch (error) { this.log.error(error); callback(); } } async prepareStream(request, callback) { this.log.debug(`stream prepare request with session id ${request.sessionID} was received.`); const [videoReturnPort, audioReturnPort] = await Promise.all([ pickPort({ type: 'udp' }), pickPort({ type: 'udp' }), ]); const videoSSRC = HAP.CameraController.generateSynchronisationSource(); const audioSSRC = HAP.CameraController.generateSynchronisationSource(); const srtpBuffer = (m) => Buffer.concat([m.srtp_key, m.srtp_salt]); const sessionInfo = { address: request.targetAddress, ipv6: request.addressVersion === 'ipv6', videoPort: request.video.port, videoReturnPort, videoCryptoSuite: request.video.srtpCryptoSuite, videoSRTP: srtpBuffer(request.video), videoSSRC, audioPort: request.audio.port, audioReturnPort, audioCryptoSuite: request.audio.srtpCryptoSuite, audioSRTP: srtpBuffer(request.audio), audioSSRC, }; this.pendingSessions.set(request.sessionID, sessionInfo); const response = { video: this.buildMediaResponse(videoReturnPort, videoSSRC, request.video), audio: this.buildMediaResponse(audioReturnPort, audioSSRC, request.audio), }; callback(undefined, response); } buildMediaResponse(returnPort, ssrc, media) { return { port: returnPort, ssrc, srtp_key: media.srtp_key, srtp_salt: media.srtp_salt, }; } async startStream(request, callback) { const sessionInfo = this.pendingSessions.get(request.sessionID); if (!sessionInfo) { this.log.error('Error finding session information.'); callback(new Error('Error finding session information')); return; } try { const activeSession = {}; activeSession.socket = this.createKeepAliveSocket(sessionInfo, request, activeSession); const { videoParams, audioParams } = await this.buildStreamParameters(sessionInfo, request); const isP2P = await this.configureStreamInput(videoParams, audioParams); await this.startFFmpegProcesses(activeSession, videoParams, audioParams, request, callback, isP2P); await this.setupTalkback(activeSession, sessionInfo); this.finalizeSession(request.sessionID, activeSession); // Opportunistically capture a snapshot from the running livestream this.captureSnapshotFromLivestream(); } catch (error) { this.log.error('Stream could not be started: ' + error); callback(error); this.pendingSessions.delete(request.sessionID); } } /** * Creates a UDP socket that monitors RTCP keep-alive messages. * If no message is received within 5x the RTCP interval, the stream is considered inactive. */ createKeepAliveSocket(sessionInfo, request, activeSession) { const socket = createSocket(sessionInfo.ipv6 ? 'udp6' : 'udp4'); socket.on('error', (err) => { this.log.error('Socket error: ' + err.message); this.stopStream(request.sessionID); }); socket.on('message', () => { if (activeSession.timeout) { clearTimeout(activeSession.timeout); } activeSession.timeout = setTimeout(() => { this.log.debug('Device appears to be inactive. Stopping video stream.'); this.controller?.forceStopStreamingSession(request.sessionID); this.stopStream(request.sessionID); }, request.video.rtcp_interval * 5 * 1000); }); socket.bind(sessionInfo.videoReturnPort); return socket; } /** * Builds FFmpeg parameters for video and (optionally) audio streams. */ async buildStreamParameters(sessionInfo, request) { const videoParams = await FFmpegParameters.forVideo(this.videoConfig.debug); videoParams.setup(this.camera.cameraConfig, request); videoParams.setRTPTarget(sessionInfo, request); const isCodecSupported = request.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */ || request.audio.codec === "AAC-eld" /* AudioStreamingCodecType.AAC_ELD */; if (!isCodecSupported) { this.log.warn(`An unsupported audio codec (type: ${request.audio.codec}) was requested. Audio streaming will be omitted.`); } let audioParams; if (isCodecSupported) { audioParams = await FFmpegParameters.forAudio(this.videoConfig.debug); audioParams.setup(this.camera.cameraConfig, request); audioParams.setRTPTarget(sessionInfo, request); } return { videoParams, audioParams }; } /** * Configures the input source (RTSP URL or P2P livestream) for the FFmpeg parameters. * * @returns `true` when the stream is a P2P livestream, `false` for RTSP. */ async configureStreamInput(videoParams, audioParams) { if (isRtspReady(this.device, this.camera.cameraConfig)) { const url = this.device.getPropertyValue(PropertyName.DeviceRTSPStreamUrl); this.log.debug('RTSP URL: ' + url); videoParams.setInputSource(url); audioParams?.setInputSource(url); return false; } this.log.debug(`Using P2P local livestream for ${this.device.getName()} ` + `(serial: ${this.device.getSerial()}, type: ${this.device.getDeviceType()})`); const streamData = await this.localLivestreamManager.getLocalLiveStream(); this.log.debug('Livestream obtained successfully. Setting up FFmpeg input streams...'); await videoParams.setInputStream(streamData.videostream); await audioParams?.setInputStream(streamData.audiostream); this.log.debug('FFmpeg input streams configured.'); return true; } /** Delay before extracting a snapshot from a running livestream (ms). */ static LIVESTREAM_SNAPSHOT_DELAY_MS = 2_000; /** Grace period for a P2P audio process before it is killed for inactivity (ms). */ static P2P_AUDIO_TIMEOUT_MS = 8_000; /** * Starts the video (and optionally separate audio) FFmpeg processes. * * For P2P streams the video and audio inputs arrive on separate TCP * connections. Some camera models advertise an audio codec in their * stream metadata but never deliver any audio data. When video and * audio share a single FFmpeg process the stalled audio input blocks * video output, causing HomeKit to time out the stream. * * To prevent that, P2P streams always use separate FFmpeg processes: * the video process starts immediately and independently of the audio * process. If the audio process fails to produce output within * {@link P2P_AUDIO_TIMEOUT_MS} it is killed silently so that resources * are not wasted on a stalled input. */ async startFFmpegProcesses(activeSession, videoParams, audioParams, request, callback, isP2P = false) { const videoProcess = new FFmpeg('[Video Process]', !isP2P && audioParams ? [videoParams, audioParams] : videoParams); videoProcess.on('started', () => callback()); videoProcess.on('error', (error) => { this.log.error('Video process ended with error: ' + error); this.stopStream(request.sessionID); }); activeSession.videoProcess = videoProcess; await videoProcess.start(); if (isP2P && audioParams) { const audioProcess = new FFmpeg('[Audio Process]', audioParams); if (isP2P) { // For P2P, audio failure must not tear down the video stream. // Set up a timeout: if FFmpeg never reports progress (i.e. the // camera is not providing audio data), kill the process early. let audioStarted = false; const audioTimeout = setTimeout(() => { if (!audioStarted) { this.log.warn(`No audio data received from ${this.device.getName()} — ` + 'killing audio process (video continues).'); audioProcess.stop(); activeSession.audioProcess = undefined; } }, StreamingDelegate.P2P_AUDIO_TIMEOUT_MS); audioProcess.on('started', () => { audioStarted = true; clearTimeout(audioTimeout); }); audioProcess.on('error', (error) => { clearTimeout(audioTimeout); if (audioStarted) { // Audio was working but failed mid-stream — log but keep video. this.log.warn('P2P audio process ended unexpectedly: ' + error); } activeSession.audioProcess = undefined; }); } else { audioProcess.on('error', (error) => { this.log.error('Audio process ended with error: ' + error); this.stopStream(request.sessionID); }); } activeSession.audioProcess = audioProcess; await audioProcess.start(); } } /** * Sets up talkback (return audio) if enabled in the camera config. */ async setupTalkback(activeSession, sessionInfo) { if (!this.camera.cameraConfig.talkback) { return; } const talkbackParams = await FFmpegParameters.forAudio(this.videoConfig.debug); await talkbackParams.setTalkbackInput(sessionInfo); if (this.camera.cameraConfig.talkbackChannels) { talkbackParams.setTalkbackChannels(this.camera.cameraConfig.talkbackChannels); } activeSession.talkbackStream = new TalkbackStream(this.camera.platform, this.device); activeSession.returnProcess = new FFmpeg('[Talkback Process]', talkbackParams); activeSession.returnProcess.on('error', (error) => { this.log.error('Talkback process ended with error: ' + error); }); await activeSession.returnProcess.start(); activeSession.returnProcess.stdout?.pipe(activeSession.talkbackStream); } /** * Transfers session from pending to ongoing, or stops it immediately if it was cancelled. */ finalizeSession(sessionId, activeSession) { const pendingSession = this.pendingSessions.get(sessionId); this.ongoingSessions.set(sessionId, activeSession); if (pendingSession) { this.pendingSessions.delete(sessionId); } else { this.log.info('Session was cancelled before start completed. Stopping immediately.'); this.stopStream(sessionId); } } /** * Captures a snapshot from the currently running livestream after a brief * delay to allow the stream to produce stable frames. */ captureSnapshotFromLivestream() { setTimeout(() => { this.snapshotDelegate.captureSnapshotFromActiveLivestream().catch((error) => { this.log.debug('Snapshot capture from livestream failed: ' + error); }); }, StreamingDelegate.LIVESTREAM_SNAPSHOT_DELAY_MS); } handleStreamRequest(request, callback) { switch (request.type) { case "start" /* StreamRequestTypes.START */: this.log.debug(`Received request to start stream with id ${request.sessionID}`, request); this.startStream(request, callback); break; case "reconfigure" /* StreamRequestTypes.RECONFIGURE */: this.log.debug(`Reconfigure request: ${request.video.width}x${request.video.height}, ` + `${request.video.fps} fps, ${request.video.max_bit_rate} kbps (Ignored)`); callback(); break; case "stop" /* StreamRequestTypes.STOP */: this.log.debug('Receive Apple HK Stop request', request); this.stopStream(request.sessionID); callback(); break; } } stopStream(sessionId) { this.log.debug('Stopping session with id: ' + sessionId); this.pendingSessions.delete(sessionId); const session = this.ongoingSessions.get(sessionId); if (!session) { this.log.debug('No session to stop.'); return; } if (session.timeout) { clearTimeout(session.timeout); } const cleanupSteps = [ ['returnAudio FFmpeg process', () => { session.talkbackStream?.stopTalkbackStream(); session.returnProcess?.stdout?.unpipe(); session.returnProcess?.stop(); }], ['video FFmpeg process', () => session.videoProcess?.stop()], ['audio FFmpeg process', () => session.audioProcess?.stop()], ['socket', () => session.socket?.close()], ['Eufy Station livestream', () => { if (!isRtspReady(this.device, this.camera.cameraConfig)) { this.localLivestreamManager.stopLocalLiveStream(); } }], ]; for (const [label, cleanup] of cleanupSteps) { try { cleanup(); } catch (error) { this.log.error(`Error occurred terminating ${label}: ${error}`); } } this.ongoingSessions.delete(sessionId); this.log.info('Stopped video stream.'); } } //# sourceMappingURL=streamingDelegate.js.map