homebridge-loxone-proxy
Version:
Homebridge Dynamic Platform Plugin which exposes a Loxone System to Homekit.
1,004 lines • 39 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LoxoneTalkbackSession = void 0;
const ws_1 = __importDefault(require("ws"));
const crypto_1 = require("crypto");
class LoxoneTalkbackSession {
constructor(options) {
this.options = options;
this.rpcTimeoutMs = 10000;
this.authMethod = 'authenticate';
this.wsProtocol = 'webrtc-signaling';
this.pcmSampleRate = 48000;
this.pcmChannelCount = 1;
this.pcmBitsPerSample = 16;
this.pcmFramesPerChunk = 480;
this.rpcId = 0;
this.pendingRpc = new Map();
this.localIceQueue = [];
this.remoteIceQueue = [];
this.peerReady = false;
this.stopped = false;
this.started = false;
this.pcmBuffer = Buffer.alloc(0);
}
async start() {
if (this.started) {
return;
}
this.started = true;
try {
await this.connectAndAuthorize();
const info = await this.getDeviceInfo();
await this.initializePeerConnection(info);
this.options.platform.log.info(`[${this.options.cameraName}] Loxone WebRTC talkback signaling established.`);
}
catch (error) {
this.stop();
throw error;
}
}
pushPcmChunk(chunk) {
if (this.stopped || !this.audioSource || chunk.length === 0) {
return;
}
this.pcmBuffer = this.pcmBuffer.length === 0
? Buffer.from(chunk)
: Buffer.concat([this.pcmBuffer, chunk]);
const frameBytes = this.pcmFramesPerChunk * this.pcmChannelCount * (this.pcmBitsPerSample / 8);
while (this.pcmBuffer.length >= frameBytes) {
const frame = this.pcmBuffer.subarray(0, frameBytes);
this.pcmBuffer = this.pcmBuffer.subarray(frameBytes);
const samples = new Int16Array(this.pcmFramesPerChunk);
Buffer.from(samples.buffer).set(frame);
try {
this.audioSource.onData({
samples,
sampleRate: this.pcmSampleRate,
bitsPerSample: this.pcmBitsPerSample,
channelCount: this.pcmChannelCount,
numberOfFrames: this.pcmFramesPerChunk,
});
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Failed to feed PCM frame to WebRTC source: ${message}`);
}
}
}
stop() {
var _a, _b, _c, _d, _e;
if (this.stopped) {
return;
}
this.stopped = true;
this.clearAuthTimers();
try {
this.sendJson({
jsonrpc: '2.0',
method: 'hangup',
id: this.rpcId++,
});
}
catch (_f) {
}
this.rejectAllPendingRpc(new Error('Talkback stopped'));
this.rejectReadyPromise(new Error('Talkback stopped'));
try {
(_b = (_a = this.localTrack) === null || _a === void 0 ? void 0 : _a.stop) === null || _b === void 0 ? void 0 : _b.call(_a);
}
catch (_g) {
}
try {
(_c = this.peerConnection) === null || _c === void 0 ? void 0 : _c.close();
}
catch (_h) {
}
this.peerConnection = undefined;
this.audioSource = undefined;
try {
(_e = (_d = this.audioSink) === null || _d === void 0 ? void 0 : _d.stop) === null || _e === void 0 ? void 0 : _e.call(_d);
}
catch (_j) {
}
this.audioSink = undefined;
this.localTrack = undefined;
this.peerReady = false;
this.localIceQueue = [];
this.remoteIceQueue = [];
this.pcmBuffer = Buffer.alloc(0);
if (this.socket) {
try {
this.socket.removeAllListeners();
this.socket.close();
}
catch (_k) {
}
}
this.socket = undefined;
}
async connectAndAuthorize() {
const wsUrl = this.toWsUrl(this.options.signalingBaseUrl);
this.readyPromise = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.socket = new ws_1.default(wsUrl, this.wsProtocol);
this.socket.on('open', () => {
this.authChallengeTimer = setTimeout(() => {
this.rejectReadyPromise(new Error('No auth challenge/ready notification received from Loxone intercom'));
}, this.rpcTimeoutMs);
});
this.socket.on('message', (data) => {
void this.handleSocketMessage(data);
});
this.socket.on('error', (error) => {
this.rejectReadyPromise(error);
});
this.socket.on('close', (code, reason) => {
const reasonText = reason.toString('utf8') || `code ${code}`;
const error = new Error(`Signaling socket closed: ${reasonText}`);
this.rejectReadyPromise(error);
if (!this.stopped) {
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone signaling socket closed: ${reasonText}`);
}
});
await this.readyPromise;
}
async getDeviceInfo() {
try {
const info = await this.callRpc('info');
if (!info || typeof info !== 'object') {
return {};
}
return info;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone signaling info() failed, continuing without TURN details: ${message}`);
return {};
}
}
async initializePeerConnection(info) {
var _a, _b;
const callState = this.numberValue(info.callState);
if (callState !== undefined && callState !== 0) {
throw new Error(`Loxone intercom is occupied (callState=${callState})`);
}
this.wrtc = this.loadWrtcModule();
const audioSourceCtor = (_a = this.wrtc.nonstandard) === null || _a === void 0 ? void 0 : _a.RTCAudioSource;
if (!audioSourceCtor) {
throw new Error('wrtc nonstandard.RTCAudioSource is not available');
}
this.audioSource = new audioSourceCtor();
this.localTrack = this.audioSource.createTrack();
const rtcConfig = {
iceServers: [
{ urls: ['stun:stun.loxonecloud.com:3478'] },
{ urls: ['stun:stun.l.google.com:19302'] },
],
iceTransportPolicy: 'all',
};
const turnUser = this.stringValue(info.turnuser);
const turnPass = this.stringValue(info.turnpass);
if (turnUser && turnPass) {
const iceServers = rtcConfig.iceServers;
iceServers.push({
urls: ['turn:stun.loxonecloud.com:3478'],
username: turnUser,
credential: turnPass,
});
}
this.peerConnection = new this.wrtc.RTCPeerConnection(rtcConfig);
this.peerConnection.onicecandidate = (event) => {
const candidate = event.candidate;
if (candidate) {
if (this.peerReady) {
void this.sendLocalIceCandidate(candidate);
}
else {
this.localIceQueue.push(candidate);
}
}
else {
if (this.stopped || !this.isSocketOpen()) {
return;
}
void this.sendNotification('iceGatheringFinished', null).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Failed to send iceGatheringFinished: ${message}`);
});
}
};
this.peerConnection.onconnectionstatechange = () => {
var _a;
const state = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.connectionState;
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone WebRTC connection state: ${state}`);
}
};
this.peerConnection.ontrack = (event) => {
var _a, _b, _c, _d;
const track = event.track;
if (!track || track.kind !== 'audio' || !this.options.onIncomingPcm) {
return;
}
const audioSinkCtor = (_b = (_a = this.wrtc) === null || _a === void 0 ? void 0 : _a.nonstandard) === null || _b === void 0 ? void 0 : _b.RTCAudioSink;
if (!audioSinkCtor) {
this.options.platform.log.debug(`[${this.options.cameraName}] wrtc nonstandard.RTCAudioSink is not available.`);
return;
}
try {
(_d = (_c = this.audioSink) === null || _c === void 0 ? void 0 : _c.stop) === null || _d === void 0 ? void 0 : _d.call(_c);
}
catch (_e) {
}
this.audioSink = new audioSinkCtor(track);
this.audioSink.ondata = (data) => {
var _a, _b;
const mono = this.toMonoPcm(data.samples, data.channelCount);
if (!mono.length) {
return;
}
(_b = (_a = this.options).onIncomingPcm) === null || _b === void 0 ? void 0 : _b.call(_a, mono);
};
};
this.peerConnection.addTransceiver('video', { direction: 'inactive' });
this.peerConnection.addTrack(this.localTrack);
const offer = await this.peerConnection.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await this.peerConnection.setLocalDescription(offer);
const offerPayload = {
type: offer.type,
sdp: (_b = offer.sdp) !== null && _b !== void 0 ? _b : '',
};
await this.negotiateCallWithFallbackModes(offerPayload);
this.peerReady = true;
await this.flushQueuedIceCandidates();
}
async applyRemoteAnswer(answerRaw, offerSdp) {
var _a, _b;
if (!this.wrtc || !this.peerConnection) {
throw new Error('Peer connection not initialized');
}
const answer = this.normalizeSessionDescription(answerRaw);
try {
await this.peerConnection.setRemoteDescription(new this.wrtc.RTCSessionDescription(answer));
return;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.toLowerCase().includes('order of m-lines')) {
throw error;
}
const offerSummary = this.describeSdpMlineOrder(offerSdp);
const answerSummary = this.describeSdpMlineOrder((_a = answer.sdp) !== null && _a !== void 0 ? _a : '');
this.options.platform.log.debug(`[${this.options.cameraName}] Remote SDP m-line mismatch. `
+ `offer=${offerSummary}; answer=${answerSummary}`);
const candidates = this.buildReorderedAnswerCandidates(offerSdp, (_b = answer.sdp) !== null && _b !== void 0 ? _b : '');
for (const candidate of candidates) {
try {
this.options.platform.log.debug(`[${this.options.cameraName}] Retrying remote SDP with candidate order: `
+ `${this.describeSdpMlineOrder(candidate)}`);
await this.peerConnection.setRemoteDescription(new this.wrtc.RTCSessionDescription({ ...answer, sdp: candidate }));
return;
}
catch (candidateError) {
const candidateMessage = candidateError instanceof Error ? candidateError.message : String(candidateError);
this.options.platform.log.debug(`[${this.options.cameraName}] Reordered SDP candidate rejected: ${candidateMessage}`);
}
}
throw error;
}
}
async negotiateCallWithFallbackModes(offerPayload) {
const errors = [];
const primarySequence = [
[offerPayload, 'add_audio', false, 0],
[offerPayload, 'add_audio', false],
];
for (const params of primarySequence) {
try {
const answerRaw = await this.callRpc('call', params);
await this.applyRemoteAnswer(answerRaw, offerPayload.sdp);
return;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`${JSON.stringify(params.slice(1))}: ${message}`);
}
}
if (!this.stopped && this.isSocketOpen()) {
try {
await this.sendNotification('hangup', null);
}
catch (_a) {
}
}
const legacyFallbackModes = [
[offerPayload, 'new', false],
[offerPayload, 'new', false, 0],
];
for (const params of legacyFallbackModes) {
try {
const answerRaw = await this.callRpc('call', params);
await this.applyRemoteAnswer(answerRaw, offerPayload.sdp);
return;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`${JSON.stringify(params.slice(1))}: ${message}`);
}
}
throw new Error(`Loxone call() failed for all modes: ${errors.join(' | ')}`);
}
async flushQueuedIceCandidates() {
var _a;
while (this.localIceQueue.length > 0) {
const candidate = this.localIceQueue.shift();
if (!candidate) {
continue;
}
await this.sendLocalIceCandidate(candidate);
}
while (this.remoteIceQueue.length > 0) {
const candidate = this.remoteIceQueue.shift();
if (!candidate) {
continue;
}
try {
await ((_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.addIceCandidate(candidate));
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Failed to flush remote ICE candidate: ${message}`);
}
}
}
async sendLocalIceCandidate(candidate) {
var _a;
if (!candidate.candidate) {
return;
}
try {
await this.callRpc('addIceCandidate', [
candidate.candidate,
(_a = candidate.sdpMLineIndex) !== null && _a !== void 0 ? _a : 0,
]);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Failed to send local ICE candidate: ${message}`);
}
}
async handleSocketMessage(data) {
var _a, _b;
const payload = this.rawDataToString(data);
let rpc;
try {
rpc = JSON.parse(payload);
}
catch (error) {
this.options.platform.log.debug(`[${this.options.cameraName}] Invalid JSON received from signaling channel.`);
return;
}
if (typeof rpc.method === 'string') {
if (typeof rpc.id === 'number') {
await this.handleRpcRequest(rpc.id, rpc.method, (_a = rpc.params) !== null && _a !== void 0 ? _a : []);
}
else {
this.handleNotification(rpc.method, (_b = rpc.params) !== null && _b !== void 0 ? _b : []);
}
return;
}
if (typeof rpc.id === 'number') {
this.handleRpcResponse(rpc);
}
}
async handleRpcRequest(id, method, params) {
switch (method) {
case this.authMethod: {
try {
const authData = this.createAuthResponse(params);
this.sendRpcSuccess(id, authData);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone auth response fallback (no encrypted payload): ${message}`);
this.sendRpcSuccess(id);
}
this.resolveReadyPromise();
return;
}
case 'addIceCandidate': {
await this.onRemoteIceCandidate(params);
this.sendRpcSuccess(id);
return;
}
case 'reachMode': {
this.sendRpcSuccess(id, [0]);
return;
}
default:
this.sendRpcError(id, -32061, `Method not found: ${method}`);
return;
}
}
handleNotification(method, _params) {
if (method === 'ready') {
this.clearAuthTimers();
this.resolveReadyPromise();
return;
}
if (method === 'kick' && !this.stopped) {
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone signaling kick received.`);
}
}
handleRpcResponse(rpc) {
var _a;
if (typeof rpc.id !== 'number') {
return;
}
const pending = this.pendingRpc.get(rpc.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRpc.delete(rpc.id);
if (rpc.error) {
pending.reject(new Error(`${pending.method}: ${rpc.error.message}`));
return;
}
pending.resolve((_a = rpc.result) === null || _a === void 0 ? void 0 : _a.data);
}
async onRemoteIceCandidate(params) {
if (!this.wrtc || !this.peerConnection) {
return;
}
const candidateStr = this.stringValue(params[0]);
if (!candidateStr) {
return;
}
const init = {
candidate: candidateStr,
sdpMLineIndex: this.numberValue(params[1]),
};
const sdpMid = this.stringValue(params[2]);
if (sdpMid) {
init.sdpMid = sdpMid;
}
const usernameFragment = this.stringValue(params[3]);
if (usernameFragment) {
init.usernameFragment = usernameFragment;
}
const candidate = new this.wrtc.RTCIceCandidate(init);
if (this.peerReady) {
try {
await this.peerConnection.addIceCandidate(candidate);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.platform.log.debug(`[${this.options.cameraName}] Failed to add remote ICE candidate: ${message}`);
}
}
else {
this.remoteIceQueue.push(candidate);
}
}
createAuthResponse(params) {
var _a, _b, _c, _d, _e;
this.clearAuthTimers();
const sessionToken = this.stringValue(params[0]);
const modulus = this.stringValue(params[1]);
const exponent = this.stringValue(params[2]);
const fullPublicKey = this.stringValue(params[3]);
const communicationToken = this.options.getToken();
if (!sessionToken) {
throw new Error('Missing signaling session token');
}
if (!communicationToken) {
throw new Error('No active Loxone communication token available');
}
this.options.platform.log.debug(`[${this.options.cameraName}] Loxone auth challenge: modulus=${(_a = modulus === null || modulus === void 0 ? void 0 : modulus.length) !== null && _a !== void 0 ? _a : 0}, `
+ `exponent=${(_b = exponent === null || exponent === void 0 ? void 0 : exponent.length) !== null && _b !== void 0 ? _b : 0}, fullPublicKey=${fullPublicKey ? 'yes' : 'no'}`);
const aesKey = (0, crypto_1.randomBytes)(32);
const iv = (0, crypto_1.randomBytes)(16);
const cipher = (0, crypto_1.createCipheriv)('aes-256-cbc', aesKey, iv);
const encryptedToken = Buffer.concat([
cipher.update(communicationToken, 'utf8'),
cipher.final(),
]).toString('base64');
const rsaPayload = `${aesKey.toString('hex')}:${iv.toString('hex')}:${sessionToken}`;
let publicKey;
try {
publicKey = this.createRsaPublicKey(modulus, exponent, fullPublicKey);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse RSA key (modulusLen=${(_c = modulus === null || modulus === void 0 ? void 0 : modulus.length) !== null && _c !== void 0 ? _c : 0}, `
+ `exponentLen=${(_d = exponent === null || exponent === void 0 ? void 0 : exponent.length) !== null && _d !== void 0 ? _d : 0}, fullKeyLen=${(_e = fullPublicKey === null || fullPublicKey === void 0 ? void 0 : fullPublicKey.length) !== null && _e !== void 0 ? _e : 0}): ${message}`);
}
let rsaEncrypted;
try {
rsaEncrypted = (0, crypto_1.publicEncrypt)({
key: publicKey,
padding: crypto_1.constants.RSA_PKCS1_PADDING,
}, Buffer.from(rsaPayload, 'utf8')).toString('base64');
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`RSA encryption failed: ${message}`);
}
return [this.options.username, rsaEncrypted, encryptedToken];
}
createRsaPublicKey(modulus, exponent, fullPublicKey) {
const errors = [];
if (modulus && exponent) {
try {
return this.createRsaPublicKeyFromComponents(modulus, exponent);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`components: ${message}`);
}
}
if (fullPublicKey) {
try {
return this.createRsaPublicKeyFromText(fullPublicKey);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`fullPublicKey: ${message}`);
}
}
if (!modulus && !fullPublicKey) {
throw new Error('Missing RSA key parameters from signaling challenge');
}
throw new Error(`Unable to parse RSA public key (${errors.join(' | ') || 'unknown format'})`);
}
createRsaPublicKeyFromComponents(modulus, exponent) {
const jwk = {
kty: 'RSA',
n: this.bigIntValueToBase64Url(modulus),
e: this.bigIntValueToBase64Url(exponent),
};
return (0, crypto_1.createPublicKey)({
key: jwk,
format: 'jwk',
});
}
createRsaPublicKeyFromText(fullPublicKey) {
const trimmed = fullPublicKey.trim();
if (!trimmed) {
throw new Error('empty public key');
}
if (trimmed.includes('-----BEGIN PUBLIC KEY-----')
|| trimmed.includes('-----BEGIN RSA PUBLIC KEY-----')) {
return (0, crypto_1.createPublicKey)(trimmed);
}
if (trimmed.startsWith('{')) {
try {
return (0, crypto_1.createPublicKey)({
key: JSON.parse(trimmed),
format: 'jwk',
});
}
catch (_a) {
}
}
const candidates = this.decodeBinaryKeyCandidates(trimmed);
let lastError;
for (const candidate of candidates) {
try {
return (0, crypto_1.createPublicKey)({ key: candidate, format: 'der', type: 'spki' });
}
catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
try {
return (0, crypto_1.createPublicKey)({ key: candidate, format: 'der', type: 'pkcs1' });
}
catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
throw new Error(lastError !== null && lastError !== void 0 ? lastError : 'unsupported key encoding');
}
async callRpc(method, params) {
if (!this.socket || this.socket.readyState !== ws_1.default.OPEN) {
throw new Error(`Socket not ready for method: ${method}`);
}
const id = this.rpcId++;
const rpc = {
jsonrpc: '2.0',
method,
id,
};
if (Array.isArray(params)) {
rpc.params = params;
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`Timeout: ${method}`));
}, this.rpcTimeoutMs);
this.pendingRpc.set(id, {
method,
resolve,
reject,
timeout,
});
this.sendJson(rpc);
});
}
async sendNotification(method, params) {
const rpc = {
jsonrpc: '2.0',
method,
};
if (Array.isArray(params)) {
rpc.params = params;
}
this.sendJson(rpc);
}
sendRpcSuccess(id, data) {
const result = {
code: 200,
message: 'Ok',
};
if (data !== undefined) {
result.data = data;
}
this.sendJson({
jsonrpc: '2.0',
id,
result,
});
}
sendRpcError(id, code, message, data) {
this.sendJson({
jsonrpc: '2.0',
id,
error: {
code,
message,
...(data !== undefined ? { data } : {}),
},
});
}
sendJson(payload) {
if (!this.socket || this.socket.readyState !== ws_1.default.OPEN) {
throw new Error('Socket is not open');
}
this.socket.send(JSON.stringify(payload));
}
isSocketOpen() {
return !!this.socket && this.socket.readyState === ws_1.default.OPEN;
}
rejectAllPendingRpc(error) {
for (const [, pending] of this.pendingRpc) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRpc.clear();
}
resolveReadyPromise() {
if (this.resolveReady) {
this.resolveReady();
}
this.resolveReady = undefined;
this.rejectReady = undefined;
}
rejectReadyPromise(error) {
if (this.rejectReady) {
this.rejectReady(error);
}
this.resolveReady = undefined;
this.rejectReady = undefined;
}
clearAuthTimers() {
if (this.authChallengeTimer) {
clearTimeout(this.authChallengeTimer);
this.authChallengeTimer = undefined;
}
}
toWsUrl(baseUrl) {
return baseUrl.replace(/^https?:\/\//i, (protocol) => protocol.toLowerCase() === 'https://' ? 'wss://' : 'ws://');
}
normalizeSessionDescription(value) {
var _a;
if (!value || typeof value !== 'object') {
throw new Error('Invalid remote SDP response');
}
const objectValue = value;
const type = this.stringValue(objectValue.type);
if (!type) {
throw new Error('Remote SDP response missing type');
}
const sdp = (_a = this.stringValue(objectValue.sdp)) !== null && _a !== void 0 ? _a : '';
return { type, sdp };
}
buildReorderedAnswerCandidates(offerSdp, answerSdp) {
const offerParts = this.splitSdpSections(offerSdp);
const answerParts = this.splitSdpSections(answerSdp);
if (!offerParts || !answerParts) {
return [];
}
const { mediaSections: offerSections } = offerParts;
const { mediaSections: answerSections } = answerParts;
if (offerSections.length !== answerSections.length) {
return [];
}
const candidates = [];
const byMid = this.reorderSectionsByMid(offerSections, answerSections);
if (byMid) {
candidates.push(this.composeSdpWithOrderedSections(answerParts, byMid));
}
const byType = this.reorderSectionsByType(offerSections, answerSections);
if (byType) {
candidates.push(this.composeSdpWithOrderedSections(answerParts, byType));
}
const permutations = this.permuteSections(answerSections);
for (const permutation of permutations) {
candidates.push(this.composeSdpWithOrderedSections(answerParts, permutation));
}
return [...new Set(candidates)];
}
splitSdpSections(sdp) {
if (!sdp.includes('m=')) {
return undefined;
}
const lineEnding = sdp.includes('\r\n') ? '\r\n' : '\n';
const normalized = sdp.replace(/\r\n/g, '\n');
const chunks = normalized.split('\nm=');
if (chunks.length < 2) {
return undefined;
}
const session = `${chunks[0]}\n`;
const mediaSections = chunks.slice(1).map((chunk) => `m=${chunk}`);
return {
session,
mediaSections: mediaSections.map((section) => section.replace(/\n/g, lineEnding)),
lineEnding,
};
}
getSdpMediaType(section) {
const firstLine = section.split(/\r?\n/, 1)[0];
const match = /^m=([^\s]+)/.exec(firstLine);
return match === null || match === void 0 ? void 0 : match[1];
}
getSdpMid(section) {
const match = section.match(/^a=mid:([^\r\n]+)/m);
return match === null || match === void 0 ? void 0 : match[1];
}
reorderSectionsByMid(offerSections, answerSections) {
const answerByMid = new Map();
for (const section of answerSections) {
const mid = this.getSdpMid(section);
if (mid) {
answerByMid.set(mid, section);
}
}
const ordered = [];
for (const offerSection of offerSections) {
const offerMid = this.getSdpMid(offerSection);
if (!offerMid) {
return undefined;
}
const section = answerByMid.get(offerMid);
if (!section) {
return undefined;
}
ordered.push(section);
}
return ordered;
}
reorderSectionsByType(offerSections, answerSections) {
var _a;
const availableByType = new Map();
for (const section of answerSections) {
const mediaType = this.getSdpMediaType(section);
if (!mediaType) {
continue;
}
const bucket = (_a = availableByType.get(mediaType)) !== null && _a !== void 0 ? _a : [];
bucket.push(section);
availableByType.set(mediaType, bucket);
}
const ordered = [];
for (const offerSection of offerSections) {
const mediaType = this.getSdpMediaType(offerSection);
if (!mediaType) {
return undefined;
}
const bucket = availableByType.get(mediaType);
const nextSection = bucket === null || bucket === void 0 ? void 0 : bucket.shift();
if (!nextSection) {
return undefined;
}
ordered.push(nextSection);
}
return ordered;
}
composeSdpWithOrderedSections(parts, orderedSections) {
const withBundle = this.rewriteBundleLine(parts.session, orderedSections, parts.lineEnding);
return `${withBundle}${orderedSections.join('')}`;
}
rewriteBundleLine(session, orderedSections, lineEnding) {
const mids = orderedSections
.map((section) => this.getSdpMid(section))
.filter((value) => !!value);
if (!mids.length) {
return session;
}
const normalized = session.replace(/\r\n/g, '\n');
const replaced = normalized.replace(/^a=group:BUNDLE[^\n]*$/m, `a=group:BUNDLE ${mids.join(' ')}`);
return replaced.replace(/\n/g, lineEnding);
}
permuteSections(sections) {
if (sections.length <= 1) {
return [sections.slice()];
}
if (sections.length > 4) {
return [];
}
const results = [];
const used = new Array(sections.length).fill(false);
const current = [];
const dfs = () => {
if (current.length === sections.length) {
results.push(current.slice());
return;
}
for (let i = 0; i < sections.length; i++) {
if (used[i]) {
continue;
}
used[i] = true;
current.push(sections[i]);
dfs();
current.pop();
used[i] = false;
}
};
dfs();
return results;
}
describeSdpMlineOrder(sdp) {
const parts = this.splitSdpSections(sdp);
if (!parts) {
return 'none';
}
return parts.mediaSections
.map((section) => {
var _a, _b;
const type = (_a = this.getSdpMediaType(section)) !== null && _a !== void 0 ? _a : '?';
const mid = (_b = this.getSdpMid(section)) !== null && _b !== void 0 ? _b : '?';
return `${type}:${mid}`;
})
.join(',');
}
rawDataToString(data) {
if (typeof data === 'string') {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString('utf8');
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString('utf8');
}
return Buffer.from(data).toString('utf8');
}
stringValue(value) {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
numberValue(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
toMonoPcm(samples, channelCount) {
if (channelCount <= 1) {
return Buffer.from(samples.buffer.slice(samples.byteOffset, samples.byteOffset + samples.byteLength));
}
const frameCount = Math.floor(samples.length / channelCount);
if (!frameCount) {
return Buffer.alloc(0);
}
const mono = new Int16Array(frameCount);
for (let frame = 0; frame < frameCount; frame++) {
let sum = 0;
for (let channel = 0; channel < channelCount; channel++) {
sum += samples[frame * channelCount + channel];
}
mono[frame] = Math.max(-32768, Math.min(32767, Math.round(sum / channelCount)));
}
return Buffer.from(mono.buffer.slice(0));
}
hexToBase64Url(hex) {
const normalized = hex.length % 2 === 0 ? hex : `0${hex}`;
return this.bufferToBase64Url(Buffer.from(normalized, 'hex'));
}
bigIntValueToBase64Url(value) {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('empty RSA component');
}
if (/^[0-9]+$/.test(trimmed)) {
return this.bufferToBase64Url(this.bigIntToBuffer(BigInt(trimmed)));
}
if (/^[a-fA-F0-9]+$/.test(trimmed)) {
return this.hexToBase64Url(trimmed);
}
return this.bufferToBase64Url(this.decodeBase64Like(trimmed));
}
decodeBinaryKeyCandidates(value) {
const normalized = value.replace(/\s+/g, '');
const candidates = [];
if (/^[a-fA-F0-9]+$/.test(normalized)) {
const hex = normalized.length % 2 === 0 ? normalized : `0${normalized}`;
candidates.push(Buffer.from(hex, 'hex'));
}
try {
candidates.push(this.decodeBase64Like(normalized));
}
catch (_a) {
}
const unique = new Map();
for (const candidate of candidates) {
if (candidate.length > 0) {
unique.set(candidate.toString('hex'), candidate);
}
}
return [...unique.values()];
}
decodeBase64Like(value) {
const normalized = value.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
const padding = (4 - (normalized.length % 4)) % 4;
const padded = `${normalized}${'='.repeat(padding)}`;
const decoded = Buffer.from(padded, 'base64');
if (decoded.length === 0) {
throw new Error('invalid base64 payload');
}
return decoded;
}
bigIntToBuffer(value) {
if (value < BigInt(0)) {
throw new Error('negative RSA component');
}
const hex = value.toString(16);
return Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, 'hex');
}
bufferToBase64Url(buffer) {
let startIndex = 0;
while (startIndex < buffer.length - 1 && buffer[startIndex] === 0) {
startIndex++;
}
return buffer
.subarray(startIndex)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
loadWrtcModule() {
const moduleValue = require('wrtc');
if (!(moduleValue === null || moduleValue === void 0 ? void 0 : moduleValue.RTCPeerConnection)) {
throw new Error('wrtc module does not expose RTCPeerConnection');
}
return moduleValue;
}
}
exports.LoxoneTalkbackSession = LoxoneTalkbackSession;
//# sourceMappingURL=LoxoneTalkback.js.map