UNPKG

homebridge-loxone-proxy

Version:

Homebridge Dynamic Platform Plugin which exposes a Loxone System to Homekit.

752 lines 34.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.streamingDelegate = void 0; const http_1 = require("http"); const https_1 = require("https"); const camera_utils_1 = require("@homebridge/camera-utils"); const child_process_1 = require("child_process"); const dgram_1 = require("dgram"); const FfmpegStreamingProcess_1 = require("./FfmpegStreamingProcess"); const LoxoneTalkback_1 = require("./LoxoneTalkback"); const RecordingDelegate_1 = require("./RecordingDelegate"); class streamingDelegate { constructor(platform, streamUrl, base64auth, cameraName, snapshotUrl, twoWayAudioTemplateVars, twoWayAudioContext) { var _a, _b, _c, _d, _e, _f, _g; this.platform = platform; this.pendingSessions = {}; this.ongoingSessions = {}; this.cachedSnapshot = null; this.cachedAt = 0; this.cacheTtlMs = 5000; this.isShuttingDown = false; this.hap = this.platform.api.hap; this.streamUrl = streamUrl; this.base64auth = base64auth; this.cameraName = cameraName; this.snapshotUrl = snapshotUrl; this.twoWayAudioTemplateVars = twoWayAudioTemplateVars; this.twoWayAudioContext = twoWayAudioContext; this.twoWayAudioOutputArgs = (_b = (_a = this.platform.config) === null || _a === void 0 ? void 0 : _a.Advanced) === null || _b === void 0 ? void 0 : _b.TwoWayAudioOutputArgs; const isTwoWayEnabledInConfig = (_e = (_d = (_c = this.platform.config) === null || _c === void 0 ? void 0 : _c.Advanced) === null || _d === void 0 ? void 0 : _d.EnableTwoWayAudio) !== null && _e !== void 0 ? _e : false; const useLoxoneAutoMode = isTwoWayEnabledInConfig && ((_f = this.twoWayAudioContext) === null || _f === void 0 ? void 0 : _f.mode) === 'loxone-intercom-v2'; const useCustomArgsMode = isTwoWayEnabledInConfig && !!this.twoWayAudioOutputArgs; if (useLoxoneAutoMode) { this.twoWayAudioMode = 'loxone-intercom-v2'; this.twoWayAudioEnabled = true; this.platform.log.info(`[${this.cameraName}] Two-way audio enabled (automatic Loxone mode).`); } else if (useCustomArgsMode) { this.twoWayAudioMode = 'custom-args'; this.twoWayAudioEnabled = true; this.platform.log.info(`[${this.cameraName}] Two-way audio enabled (custom FFmpeg output args).`); } else { this.twoWayAudioMode = 'disabled'; this.twoWayAudioEnabled = false; if (isTwoWayEnabledInConfig) { this.platform.log.warn(`[${this.cameraName}] Two-way audio requested, but no compatible mode is available. ` + 'For non-Loxone cameras set Advanced.TwoWayAudioOutputArgs.'); } } const enableHKSV = (_g = this.platform.config.enableHKSV) !== null && _g !== void 0 ? _g : false; if (enableHKSV) { this.recordingDelegate = new RecordingDelegate_1.RecordingDelegate(this.platform, this.streamUrl, this.base64auth, this.cameraName); this.platform.log.info(`[${this.cameraName}] HKSV is Activated for this configuration.`); } else { this.recordingDelegate = undefined; this.platform.log.info(`[${this.cameraName}] HKSV is Disabled for this configuration.`); } platform.api.on("shutdown", () => { this.isShuttingDown = true; this.platform.log.debug(`[${this.cameraName}] Streaming delegate is shutting down`); Object.keys(this.ongoingSessions).forEach((sessionId) => this.stopStream(sessionId)); Object.values(this.pendingSessions).forEach((session) => { var _a; return (_a = session.returnAudioSplitter) === null || _a === void 0 ? void 0 : _a.close(); }); this.pendingSessions = {}; }); const resolutions = [ [320, 180, 30], [320, 240, 15], [320, 240, 30], [480, 270, 30], [480, 360, 30], [640, 360, 30], [640, 480, 30], [1024, 768, 30], [1280, 720, 30], ]; const streamingOptions = { supportedCryptoSuites: [0], video: { codec: { profiles: [0, 1, 2], levels: [0, 1, 2], }, resolutions: resolutions, }, audio: { twoWayAudio: this.twoWayAudioEnabled, codecs: [ { type: "AAC-eld", samplerate: 16, }, { type: "OPUS", samplerate: 24, }, ], }, }; const recordingOptions = { overrideEventTriggerOptions: [ 1, 2, ], prebufferLength: 4 * 1000, mediaContainerConfiguration: [ { type: 0, fragmentLength: 4000, }, ], video: { parameters: { profiles: [ 0, 1, 2, ], levels: [ 0, 1, 2, ], }, resolutions: resolutions, type: 0, }, audio: { codecs: [ { samplerate: 3, type: 0, }, ], }, }; const options = { cameraStreamCount: 5, delegate: this, streamingOptions: streamingOptions, ...(this.recordingDelegate ? { recording: { options: recordingOptions, delegate: this.recordingDelegate, }, } : {}), }; this.controller = new this.hap.CameraController(options); } stopStream(sessionId) { var _a, _b, _c, _d, _e; const session = this.ongoingSessions[sessionId]; if (session) { if (session.timeout) { clearTimeout(session.timeout); } try { (_a = session.socket) === null || _a === void 0 ? void 0 : _a.close(); } catch (error) { this.platform.log.error(`[${this.cameraName}] Error occurred closing socket: ${error}`); } try { (_b = session.mainProcess) === null || _b === void 0 ? void 0 : _b.stop(); } catch (error) { this.platform.log.error(`[${this.cameraName}] Error occurred terminating main FFmpeg process: ${error}`); } try { (_c = session.homekitAudioProcess) === null || _c === void 0 ? void 0 : _c.stop(); } catch (error) { this.platform.log.error(`[${this.cameraName}] Error occurred terminating HomeKit audio process: ${error}`); } this.detachReturnAudioStdout(session); try { (_d = session.loxoneTalkback) === null || _d === void 0 ? void 0 : _d.stop(); } catch (error) { this.platform.log.error(`[${this.cameraName}] Error occurred terminating Loxone talkback session: ${error}`); } try { (_e = session.returnProcess) === null || _e === void 0 ? void 0 : _e.stop(); } catch (error) { this.platform.log.error(`[${this.cameraName}] Error occurred terminating two-way audio process: ${error}`); } delete this.ongoingSessions[sessionId]; delete this.pendingSessions[sessionId]; this.platform.log.info(`[${this.cameraName}] Stopped video stream.`); return; } const pendingSession = this.pendingSessions[sessionId]; if (pendingSession === null || pendingSession === void 0 ? void 0 : pendingSession.returnAudioSplitter) { pendingSession.returnAudioSplitter.close(); } delete this.pendingSessions[sessionId]; } forceStopStream(sessionId) { this.controller.forceStopStreamingSession(sessionId); } async getSnapshot(useCache = true) { const now = Date.now(); if (useCache && this.cachedSnapshot && now - this.cachedAt < this.cacheTtlMs) { this.platform.log.debug(`[${this.cameraName}] Snapshot cache hit`); return this.cachedSnapshot; } return new Promise((resolve) => { this.handleSnapshotRequest({ width: 640, height: 360 }, (err, buffer) => { if (err || !buffer) { this.platform.log.warn(`[${this.cameraName}] Snapshot request failed`); return resolve(null); } this.cachedSnapshot = buffer; this.cachedAt = Date.now(); resolve(buffer); }); }); } async getSnapshotViaHTTP() { const snapshotUrl = this.snapshotUrl; if (!snapshotUrl) { return null; } return new Promise((resolve) => { const requestHeaders = {}; if (this.base64auth) { requestHeaders.Authorization = `Basic ${this.base64auth}`; } const requestFn = snapshotUrl.startsWith('https://') ? https_1.get : http_1.get; const request = requestFn(snapshotUrl, { timeout: 2000, headers: requestHeaders, }, (response) => { if (response.statusCode !== 200) { this.platform.log.debug(`[${this.cameraName}] Snapshot HTTP request failed with status ${response.statusCode}`); response.destroy(); resolve(null); return; } const buffers = []; let hasError = false; response.on('data', (chunk) => { if (!hasError) { buffers.push(chunk); } }); response.on('end', () => { if (!hasError) { resolve(Buffer.concat(buffers)); } else { resolve(null); } }); response.on('error', (error) => { hasError = true; this.platform.log.debug(`[${this.cameraName}] Snapshot HTTP response error: ${error.message}`); resolve(null); }); }); request.on('error', (error) => { this.platform.log.debug(`[${this.cameraName}] Snapshot HTTP request error: ${error.message}`); resolve(null); }); request.on('timeout', () => { this.platform.log.debug(`[${this.cameraName}] Snapshot HTTP request timeout`); request.destroy(); resolve(null); }); }); } async handleSnapshotRequest(request, callback) { this.platform.log.debug(`[${this.cameraName}] Snapshot requested: ${request.width} x ${request.height}`); try { let snapshot = null; if (this.snapshotUrl) { try { snapshot = await this.getSnapshotViaHTTP(); if (snapshot) { this.cachedSnapshot = snapshot; this.cachedAt = Date.now(); this.platform.log.debug(`[${this.cameraName}] Successfully captured snapshot via HTTP at ${request.width}x${request.height}`); callback(undefined, snapshot); return; } } catch (error) { this.platform.log.debug(`[${this.cameraName}] HTTP snapshot failed, falling back to FFmpeg: ${error instanceof Error ? error.message : String(error)}`); } } snapshot = await this.fetchSnapshot(request.width, request.height); this.cachedSnapshot = snapshot; this.cachedAt = Date.now(); this.platform.log.debug(`[${this.cameraName}] Successfully captured snapshot via FFmpeg at ${request.width}x${request.height}`); callback(undefined, snapshot); } catch (error) { this.platform.log.error(`[${this.cameraName}] Snapshot error: ${error instanceof Error ? error.message : String(error)}`); callback(error instanceof Error ? error : new Error(String(error))); } } fetchSnapshot(width, height) { return new Promise((resolve, reject) => { const ffmpegArgs = [ '-hide_banner', '-loglevel', 'error', ]; if (this.base64auth) { ffmpegArgs.push('-headers', `Authorization: Basic ${this.base64auth}\r\n`); } ffmpegArgs.push('-i', `${this.streamUrl}`, '-frames:v', '1', '-an', '-sn', '-dn', '-vf', this.buildSnapshotFilter(width, height), '-f', 'image2', '-vcodec', 'mjpeg', '-'); const ffmpeg = (0, child_process_1.spawn)(camera_utils_1.defaultFfmpegPath, ffmpegArgs, { env: process.env }); let snapshotBuffer = Buffer.alloc(0); ffmpeg.stdout.on('data', (data) => { snapshotBuffer = Buffer.concat([snapshotBuffer, data]); }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg process creation failed: ${error.message}`)); }); ffmpeg.stderr.on('data', (data) => { const line = data.toString(); if (/error|failed|unable|not found/i.test(line)) { this.platform.log.error(`[${this.cameraName}] Snapshot error: ${line.trim()}`); } }); ffmpeg.on('close', (code, signal) => { if (signal) { reject(new Error(`Snapshot process was killed with signal: ${signal}`)); } else if (code === 0) { if (snapshotBuffer.length > 0) { resolve(snapshotBuffer); } else { reject(new Error('Failed to fetch snapshot: buffer is empty')); } } else { reject(new Error(`Snapshot process exited with code ${code}`)); } }); }); } async prepareStream(request, callback) { const videoIncomingPort = await (0, camera_utils_1.reservePorts)({ count: 1, }); const videoSSRC = this.hap.CameraController.generateSynchronisationSource(); let audioIncomingPort = await (0, camera_utils_1.reservePorts)({ count: 1, }); const audioSSRC = this.hap.CameraController.generateSynchronisationSource(); let returnAudioSplitter; if (this.twoWayAudioEnabled) { returnAudioSplitter = new camera_utils_1.RtpSplitter(); audioIncomingPort = [await returnAudioSplitter.portPromise]; } const sessionInfo = { address: request.targetAddress, addressVersion: request.addressVersion, audioCryptoSuite: request.audio.srtpCryptoSuite, audioPort: request.audio.port, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), audioSRTPKey: request.audio.srtp_key, audioSRTPSalt: request.audio.srtp_salt, audioSSRC: audioSSRC, audioIncomingPort: audioIncomingPort[0], returnAudioSplitter, videoCryptoSuite: request.video.srtpCryptoSuite, videoPort: request.video.port, videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), videoSSRC: videoSSRC, videoIncomingPort: videoIncomingPort[0], }; const response = { video: { port: sessionInfo.videoIncomingPort, ssrc: videoSSRC, srtp_key: request.video.srtp_key, srtp_salt: request.video.srtp_salt, }, audio: { port: sessionInfo.audioIncomingPort, ssrc: audioSSRC, srtp_key: request.audio.srtp_key, srtp_salt: request.audio.srtp_salt, }, }; this.pendingSessions[request.sessionID] = sessionInfo; callback(undefined, response); } async handleStreamRequest(request, callback) { switch (request.type) { case "start": { this.platform.log.info(`[${this.cameraName}] Started video stream: ${request.video.width}x${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps`); await this.startStream(request, callback); break; } case "reconfigure": { this.platform.log.debug(`[${this.cameraName}] Reconfigure stream requested: ${request.video.width}x${request.video.height}, ${request.video.fps} fps, ${request.video.max_bit_rate} kbps (Ignored)`); callback(); break; } case "stop": { this.platform.log.debug(`[${this.cameraName}] Stop stream requested`); this.stopStream(request.sessionID); callback(); break; } } } async startStream(request, callback) { const sessionInfo = this.pendingSessions[request.sessionID]; if (!sessionInfo) { this.platform.log.error(`[${this.cameraName}] Error finding session information.`); callback(new Error('Error finding session information')); return; } const mtu = request.video.mtu > 0 ? request.video.mtu : 1316; const fps = this.clamp(Math.round(request.video.fps || 25), 2, 30); const requestedWidth = Math.round(request.video.width || 1280); const requestedHeight = Math.round(request.video.height || 720); const requestedBitrate = Math.round(request.video.max_bit_rate || 299); const minLiveWidth = 960; const minLiveHeight = 540; const minLiveBitrate = 512; const targetWidth = this.clamp(Math.max(requestedWidth, minLiveWidth), minLiveWidth, 1920); const targetHeight = this.clamp(Math.max(requestedHeight, minLiveHeight), minLiveHeight, 1080); const videoBitrate = this.clamp(Math.max(requestedBitrate, minLiveBitrate), minLiveBitrate, 4096); const payloadType = request.video.pt || 99; const keyframeInterval = Math.max(1, fps); if (requestedWidth !== targetWidth || requestedHeight !== targetHeight || requestedBitrate !== videoBitrate) { this.platform.log.info(`[${this.cameraName}] Live stream quality floor applied: ` + `${requestedWidth}x${requestedHeight}@${requestedBitrate}kbps -> ` + `${targetWidth}x${targetHeight}@${videoBitrate}kbps`); } const ffmpegArgs = this.buildAuthArgs(); ffmpegArgs.push('-hide_banner', '-use_wallclock_as_timestamps', '1', '-probesize', '32', '-analyzeduration', '0', '-fflags', '+genpts+nobuffer+igndts', '-flags', 'low_delay', '-max_delay', '0', '-i', `${this.streamUrl}`, '-an', '-sn', '-dn', '-codec:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-color_range', 'mpeg', '-crf', '22', '-r', `${fps}`, '-g', `${keyframeInterval}`, '-keyint_min', `${keyframeInterval}`, '-sc_threshold', '0', '-bf', '0', '-filter:v', `fps=${fps}:round=down,scale='min(${targetWidth},iw)':'min(${targetHeight},ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2`, '-b:v', `${videoBitrate}k`, '-maxrate', `${videoBitrate}k`, '-bufsize', `${videoBitrate * 2}k`, '-payload_type', `${payloadType}`, '-ssrc', `${sessionInfo.videoSSRC}`, '-f', 'rtp', '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', sessionInfo.videoSRTP.toString('base64'), `srtp://${sessionInfo.address}:${sessionInfo.videoPort}?rtcpport=${sessionInfo.videoPort}&pkt_size=${mtu}`, '-progress', 'pipe:1'); const activeSession = {}; activeSession.socket = (0, dgram_1.createSocket)(sessionInfo.addressVersion === 'ipv6' ? 'udp6' : 'udp4'); activeSession.socket.on('error', (err) => { this.platform.log.error(`[${this.cameraName}] Socket error: ${err.message}`); this.stopStream(request.sessionID); }); activeSession.socket.on('message', () => { if (activeSession.timeout) { clearTimeout(activeSession.timeout); } activeSession.timeout = setTimeout(() => { this.platform.log.info(`[${this.cameraName}] Device appears to be inactive. Stopping stream.`); this.controller.forceStopStreamingSession(request.sessionID); this.stopStream(request.sessionID); }, request.video.rtcp_interval * 5 * 1000); }); activeSession.socket.bind(sessionInfo.videoIncomingPort); activeSession.mainProcess = new FfmpegStreamingProcess_1.FfmpegStreamingProcess(this.cameraName, request.sessionID, camera_utils_1.defaultFfmpegPath, ffmpegArgs, this.platform.log, true, this, callback); if (this.twoWayAudioEnabled) { this.startTwoWayAudio(activeSession, sessionInfo, request); } this.ongoingSessions[request.sessionID] = activeSession; delete this.pendingSessions[request.sessionID]; } startTwoWayAudio(activeSession, sessionInfo, request) { if (!sessionInfo.returnAudioSplitter) { this.platform.log.warn(`[${this.cameraName}] No RTP splitter available for two-way audio. Skipping talkback setup.`); return; } if (this.twoWayAudioMode === 'loxone-intercom-v2') { this.startLoxoneTwoWayAudio(activeSession, sessionInfo, request); return; } this.startCustomTwoWayAudio(activeSession, sessionInfo, request); } startCustomTwoWayAudio(activeSession, sessionInfo, request) { const outputArgs = this.buildTwoWayAudioOutputArgs(); if (!outputArgs.length) { this.platform.log.warn(`[${this.cameraName}] Two-way audio output args are empty. Skipping talkback setup.`); return; } try { activeSession.returnProcess = this.createReturnAudioTranscoder(sessionInfo, request, outputArgs); void activeSession.returnProcess.start() .then((splitterPort) => { this.platform.log.info(`[${this.cameraName}] Two-way audio started (RTP splitter on port ${splitterPort}).`); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); this.platform.log.error(`[${this.cameraName}] Failed to start two-way audio: ${message}`); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.platform.log.error(`[${this.cameraName}] Could not initialize two-way audio: ${message}`); } } startLoxoneTwoWayAudio(activeSession, sessionInfo, request) { const signalingBaseUrl = this.resolveLoxoneSignalingBaseUrl(); if (!signalingBaseUrl) { this.platform.log.warn(`[${this.cameraName}] Could not resolve Loxone signaling URL. Skipping two-way audio.`); return; } try { const outputArgs = [ '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '48000', 'pipe:1', ]; activeSession.returnProcess = this.createReturnAudioTranscoder(sessionInfo, request, outputArgs); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.platform.log.error(`[${this.cameraName}] Could not initialize Loxone return-audio transcoder: ${message}`); return; } void activeSession.returnProcess.start() .then(async (splitterPort) => { this.platform.log.info(`[${this.cameraName}] Loxone return-audio transcoder started (RTP splitter on port ${splitterPort}).`); const stdout = this.getReturnAudioStdout(activeSession.returnProcess); if (!stdout) { throw new Error('Unable to read PCM output from return-audio transcoder'); } const talkback = new LoxoneTalkback_1.LoxoneTalkbackSession({ platform: this.platform, cameraName: this.cameraName, signalingBaseUrl, username: this.platform.config.username, getToken: () => { var _a; return (_a = this.platform.LoxoneHandler) === null || _a === void 0 ? void 0 : _a.getActiveCommunicationToken(); }, onIncomingPcm: (chunk) => this.feedHomeKitIncomingAudio(activeSession, chunk), }); activeSession.loxoneTalkback = talkback; activeSession.returnAudioStdout = stdout; activeSession.returnAudioStdoutHandler = (chunk) => talkback.pushPcmChunk(chunk); stdout.on('data', activeSession.returnAudioStdoutHandler); await talkback.start(); activeSession.homekitAudioProcess = this.createLoxoneIncomingAudioBridge(request.sessionID, sessionInfo, request); this.platform.log.info(`[${this.cameraName}] Two-way audio started (Loxone WebRTC talkback).`); }) .catch((error) => { var _a, _b, _c; const message = error instanceof Error ? error.message : String(error); const sessionStillActive = this.ongoingSessions[request.sessionID] === activeSession; if (!sessionStillActive || this.isShuttingDown) { this.platform.log.debug(`[${this.cameraName}] Loxone two-way audio startup cancelled: ${message}`); return; } this.detachReturnAudioStdout(activeSession); (_a = activeSession.loxoneTalkback) === null || _a === void 0 ? void 0 : _a.stop(); activeSession.loxoneTalkback = undefined; (_b = activeSession.homekitAudioProcess) === null || _b === void 0 ? void 0 : _b.stop(); activeSession.homekitAudioProcess = undefined; (_c = activeSession.returnProcess) === null || _c === void 0 ? void 0 : _c.stop(); activeSession.returnProcess = undefined; this.platform.log.error(`[${this.cameraName}] Failed to start Loxone two-way audio: ${message}`); }); } createLoxoneIncomingAudioBridge(sessionId, sessionInfo, request) { const audioCodec = this.getAudioCodecArgs(request); if (!audioCodec) { this.platform.log.debug(`[${this.cameraName}] Unsupported HomeKit audio codec for inbound bridge: ${request.audio.codec}`); return undefined; } const sampleRateKhz = this.resolveAudioSampleRateKhz(request.audio.sample_rate); const audioBitrate = Math.max(16, Math.round(request.audio.max_bit_rate || 24)); const channels = Math.max(1, request.audio.channel || 1); const ffmpegArgs = [ '-hide_banner', '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '48000', '-i', 'pipe:0', '-vn', '-sn', '-dn', ...audioCodec, '-ar', `${sampleRateKhz}k`, '-ac', `${channels}`, '-b:a', `${audioBitrate}k`, '-payload_type', `${request.audio.pt}`, '-ssrc', `${sessionInfo.audioSSRC}`, '-f', 'rtp', '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', sessionInfo.audioSRTP.toString('base64'), `srtp://${sessionInfo.address}:${sessionInfo.audioPort}?rtcpport=${sessionInfo.audioPort}&pkt_size=188`, '-progress', 'pipe:1', ]; this.platform.log.info(`[${this.cameraName}] Starting Loxone inbound audio bridge to HomeKit.`); return new FfmpegStreamingProcess_1.FfmpegStreamingProcess(`${this.cameraName}-audio`, sessionId, camera_utils_1.defaultFfmpegPath, ffmpegArgs, this.platform.log, true, this, undefined, false); } getAudioCodecArgs(request) { switch (request.audio.codec) { case "OPUS": return [ '-codec:a', 'libopus', '-application', 'lowdelay', '-frame_duration', '20', '-flags', '+global_header', ]; case "AAC-eld": return [ '-codec:a', 'libfdk_aac', '-profile:a', 'aac_eld', '-flags', '+global_header', ]; default: return undefined; } } resolveAudioSampleRateKhz(value) { switch (value) { case 8: return 8; case 16: return 16; case 24: default: return 24; } } feedHomeKitIncomingAudio(session, chunk) { var _a; const stdin = (_a = session.homekitAudioProcess) === null || _a === void 0 ? void 0 : _a.getStdin(); if (!stdin || stdin.destroyed || !chunk.length) { return; } try { stdin.write(chunk); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.platform.log.debug(`[${this.cameraName}] Failed to write inbound PCM to HomeKit bridge: ${message}`); } } createReturnAudioTranscoder(sessionInfo, request, outputArgs) { return new camera_utils_1.ReturnAudioTranscoder({ ffmpegPath: camera_utils_1.defaultFfmpegPath, logger: { error: (log) => this.platform.log.error(`[${this.cameraName}] [Talkback] ${log}`), info: (log) => this.platform.log.debug(`[${this.cameraName}] [Talkback] ${log}`), }, logLabel: `${this.cameraName}-talkback`, outputArgs, returnAudioSplitter: sessionInfo.returnAudioSplitter, prepareStreamRequest: { targetAddress: sessionInfo.address, addressVersion: sessionInfo.addressVersion, audio: { srtp_key: sessionInfo.audioSRTPKey, srtp_salt: sessionInfo.audioSRTPSalt, }, }, incomingAudioOptions: { ssrc: sessionInfo.audioSSRC, rtcpPort: sessionInfo.audioPort, }, startStreamRequest: { audio: { codec: request.audio.codec, channel: request.audio.channel, sample_rate: request.audio.sample_rate, pt: request.audio.pt, }, }, }); } getReturnAudioStdout(returnProcess) { var _a, _b; if (!returnProcess) { return undefined; } const ffmpegProcess = returnProcess; return (_b = (_a = ffmpegProcess.ffmpegProcess) === null || _a === void 0 ? void 0 : _a.ff) === null || _b === void 0 ? void 0 : _b.stdout; } detachReturnAudioStdout(session) { if (session.returnAudioStdout && session.returnAudioStdoutHandler) { session.returnAudioStdout.off('data', session.returnAudioStdoutHandler); } session.returnAudioStdout = undefined; session.returnAudioStdoutHandler = undefined; } resolveLoxoneSignalingBaseUrl() { var _a; const contextUrl = (_a = this.twoWayAudioContext) === null || _a === void 0 ? void 0 : _a.signalingBaseUrl; if (contextUrl) { return contextUrl; } try { const parsed = new URL(this.streamUrl); return parsed.origin; } catch (_b) { return undefined; } } buildAuthArgs() { if (!this.base64auth) { return []; } return ['-headers', `Authorization: Basic ${this.base64auth}\r\n`]; } buildSnapshotFilter(width, height) { const safeWidth = this.clamp(Math.round(width || 640), 64, 4096); const safeHeight = this.clamp(Math.round(height || 360), 64, 2160); return `scale='min(${safeWidth},iw)':'min(${safeHeight},ih)':force_original_aspect_ratio=decrease`; } clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } buildTwoWayAudioOutputArgs() { var _a; const raw = this.twoWayAudioOutputArgs; if (!raw) { return []; } const cameraHost = this.extractHost(this.streamUrl); const expanded = raw .replace(/{camera_host}/g, cameraHost !== null && cameraHost !== void 0 ? cameraHost : '') .replace(/{stream_url}/g, this.streamUrl); const withTemplateVars = Object.entries((_a = this.twoWayAudioTemplateVars) !== null && _a !== void 0 ? _a : {}) .reduce((acc, [key, value]) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), value), expanded); return this.tokenizeFfmpegArgs(withTemplateVars); } extractHost(url) { try { return new URL(url).host; } catch (_a) { return null; } } tokenizeFfmpegArgs(command) { var _a, _b; const args = []; const re = /[^\s"']+|"([^"]*)"|'([^']*)'/g; let match; while ((match = re.exec(command)) !== null) { args.push((_b = (_a = match[1]) !== null && _a !== void 0 ? _a : match[2]) !== null && _b !== void 0 ? _b : match[0]); } return args; } } exports.streamingDelegate = streamingDelegate; //# sourceMappingURL=StreamingDelegate.js.map