UNPKG

homebridge-loxone-proxy

Version:

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

1,004 lines 39 kB
"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