homebridge-loxone-proxy
Version:
Homebridge Dynamic Platform Plugin which exposes a Loxone System to Homekit.
752 lines • 34.9 kB
JavaScript
;
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