UNPKG

homebridge-loxone-proxy

Version:

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

386 lines โ€ข 16.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RecordingDelegate = exports.parseFragmentedMP4 = exports.readLength = exports.listenServer = exports.PREBUFFER_LENGTH = void 0; const child_process_1 = require("child_process"); const events_1 = require("events"); const buffer_1 = require("buffer"); const process_1 = require("process"); const Prebuffer_1 = require("./Prebuffer"); exports.PREBUFFER_LENGTH = 4000; async function listenServer(server, log) { let isListening = false; while (!isListening) { const port = 10000 + Math.round(Math.random() * 30000); server.listen(port); try { await (0, events_1.once)(server, 'listening'); const address = server.address(); isListening = true; return address.port; } catch (e) { log.error('Error while listening to the server:', e); } } return 0; } exports.listenServer = listenServer; async function readLength(readable, length) { if (!length) { return buffer_1.Buffer.alloc(0); } const ret = readable.read(length); if (ret) { return ret; } return new Promise((resolve, reject) => { const r = () => { const data = readable.read(length); if (data) { cleanup(); resolve(data); } }; const e = () => { cleanup(); reject(new Error(`stream ended during read for minimum ${length} bytes`)); }; const c = () => { cleanup(); reject(new Error(`stream closed during read for minimum ${length} bytes`)); }; const err = (error) => { cleanup(); reject(error); }; const cleanup = () => { readable.removeListener('readable', r); readable.removeListener('end', e); readable.removeListener('close', c); readable.removeListener('error', err); }; readable.on('readable', r); readable.on('end', e); readable.on('close', c); readable.on('error', err); }); } exports.readLength = readLength; async function* parseFragmentedMP4(readable) { try { while (true) { const header = await readLength(readable, 8); const length = header.readInt32BE(0) - 8; const type = header.slice(4).toString(); const data = await readLength(readable, length); yield { header, length, type, data }; } } catch (error) { if (error instanceof Error && (error.message.includes('stream ended') || error.message.includes('stream closed'))) { return; } throw error; } } exports.parseFragmentedMP4 = parseFragmentedMP4; class RecordingDelegate { constructor(platform, streamUrl, base64auth, cameraName) { this.platform = platform; this.activeFFmpegProcesses = new Map(); this.streamAbortControllers = new Map(); this.closedStreams = new Set(); this.recordingActive = false; this.shuttingDown = false; this.log = platform.log; this.hap = platform.api.hap; this.videoProcessor = 'ffmpeg'; this.streamUrl = streamUrl; this.base64auth = base64auth; this.cameraName = cameraName; platform.api.on("shutdown", () => { var _a, _b; this.shuttingDown = true; this.log.info(`[${this.cameraName}] Shutting down recording delegate`, this.streamUrl); this.streamAbortControllers.forEach(controller => controller.abort()); this.streamAbortControllers.clear(); this.closedStreams.clear(); this.activeFFmpegProcesses.forEach(proc => { if (!proc.killed) { proc.kill('SIGTERM'); } }); this.activeFFmpegProcesses.clear(); if (((_a = this.preBufferSession) === null || _a === void 0 ? void 0 : _a.process) && !this.preBufferSession.process.killed) { this.preBufferSession.process.kill('SIGKILL'); } if ((_b = this.preBufferSession) === null || _b === void 0 ? void 0 : _b.server) { this.preBufferSession.server.close(); } }); } updateRecordingActive(active) { this.recordingActive = active; this.log.info(`[${this.cameraName}] Recording active status changed to: ${active}`, this.streamUrl); if (active) { void this.startPreBuffer().catch((error) => { this.log.warn(`[${this.cameraName}] Failed to warm prebuffer: ${error}`, this.streamUrl); }); } return Promise.resolve(); } updateRecordingConfiguration(config) { this.log.info(`[${this.cameraName}] Recording configuration updated`, this.streamUrl); this.currentRecordingConfiguration = config; if (config && this.recordingActive) { void this.startPreBuffer().catch((error) => { this.log.warn(`[${this.cameraName}] Failed to warm prebuffer after configuration update: ${error}`, this.streamUrl); }); } return Promise.resolve(); } async *handleRecordingStreamRequest(streamId) { const streamKey = this.toStreamKey(streamId); this.closedStreams.delete(streamKey); this.log.info(`[${this.cameraName}] Recording stream request received for stream ID: ${streamId}`, this.streamUrl); if (!this.currentRecordingConfiguration) { this.log.error(`[${this.cameraName}] No recording configuration available`, this.streamUrl); return; } const abortController = new AbortController(); this.streamAbortControllers.set(streamKey, abortController); let fragmentCount = 0; try { await this.startPreBuffer(); if (this.isStreamClosed(streamKey, abortController.signal)) { this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted before fragments started`, this.streamUrl); return; } const fragmentGenerator = this.handleFragmentsRequests(this.currentRecordingConfiguration, streamKey, abortController.signal); for await (const fragmentBuffer of fragmentGenerator) { if (this.isStreamClosed(streamKey, abortController.signal)) { this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted, stopping fragment yields`, this.streamUrl); return; } fragmentCount++; yield { data: fragmentBuffer, isLast: false }; } if (this.isStreamClosed(streamKey, abortController.signal)) { this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted after fragment loop`, this.streamUrl); return; } this.log.info(`[${this.cameraName}] โœ… Recording completed successfully. Total fragments: ${fragmentCount}`, this.streamUrl); yield { data: buffer_1.Buffer.alloc(0), isLast: true }; } catch (error) { if (this.isStreamClosed(streamKey, abortController.signal)) { this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted while handling recording`, this.streamUrl); return; } this.log.error(`[${this.cameraName}] โŒ Recording stream error: ${error}`, this.streamUrl); yield { data: buffer_1.Buffer.alloc(0), isLast: true }; } finally { this.streamAbortControllers.delete(streamKey); this.closedStreams.delete(streamKey); } } closeRecordingStream(streamId, reason) { var _a; const streamKey = this.toStreamKey(streamId); this.log.info(`[${this.cameraName}] Recording stream closed for stream ID: ${streamId}, reason: ${reason}`, this.streamUrl); this.closedStreams.add(streamKey); (_a = this.streamAbortControllers.get(streamKey)) === null || _a === void 0 ? void 0 : _a.abort(); this.streamAbortControllers.delete(streamKey); const process = this.activeFFmpegProcesses.get(streamKey); if (process && !process.killed) { process.kill('SIGTERM'); setTimeout(() => { if (!process.killed) { process.kill('SIGKILL'); } }, 1000); } this.activeFFmpegProcesses.delete(streamKey); } async startPreBuffer() { if (this.shuttingDown || this.preBufferSession) { return; } if (!this.preBufferInitPromise) { this.preBufferInitPromise = (async () => { this.log.info(`[${this.cameraName}] Starting prebuffer for ${this.streamUrl}`); const ffmpegInput = [ '-use_wallclock_as_timestamps', '1', '-probesize', '200000', '-analyzeduration', '0', '-fflags', '+genpts+nobuffer+igndts', '-flags', 'low_delay', '-max_delay', '0', '-thread_queue_size', '1024', '-f', 'mjpeg', '-re', '-i', this.streamUrl, ]; if (this.base64auth) { ffmpegInput.unshift('-headers', `Authorization: Basic ${this.base64auth}\r\n`); } this.preBuffer = new Prebuffer_1.PreBuffer(ffmpegInput, this.cameraName, this.videoProcessor, this.log); this.preBufferSession = await this.preBuffer.startPreBuffer(); })() .finally(() => { this.preBufferInitPromise = undefined; }); } await this.preBufferInitPromise; } async *handleFragmentsRequests(config, streamKey, abortSignal) { var _a; if (!this.preBuffer) { throw new Error('No video source configured'); } if (this.isStreamClosed(streamKey, abortSignal)) { return; } const input = await this.preBuffer.getVideo((_a = config.mediaContainerConfiguration.fragmentLength) !== null && _a !== void 0 ? _a : exports.PREBUFFER_LENGTH); if (this.isStreamClosed(streamKey, abortSignal)) { return; } const session = await this.startFFMPegFragmetedMP4Session(this.videoProcessor, input); const { cp, generator } = session; this.activeFFmpegProcesses.set(streamKey, cp); const abortHandler = () => { if (!cp.killed) { cp.kill('SIGTERM'); setTimeout(() => { if (!cp.killed) { cp.kill('SIGKILL'); } }, 1000); } }; abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', abortHandler, { once: true }); let moofBuffer = null; let pending = []; let isFirst = true; let fragmentCount = 0; try { this.log.info(`[${this.cameraName}] ๐Ÿ“น Starting video fragments generation for stream ID: ${streamKey}`, this.streamUrl); for await (const box of generator) { if (this.isStreamClosed(streamKey, abortSignal)) { return; } pending.push(box.header, box.data); if (isFirst && box.type === 'moov') { this.log.info(`[${this.cameraName}] ๐Ÿ“ฆ First moov atom received, yielding initial fragment`, this.streamUrl); if (this.isStreamClosed(streamKey, abortSignal)) { return; } yield buffer_1.Buffer.concat(pending); pending = []; isFirst = false; } else if (box.type === 'moof') { moofBuffer = buffer_1.Buffer.concat([box.header, box.data]); this.log.debug(`[${this.cameraName}] ๐Ÿ“ฆ moof atom received`, this.streamUrl); } else if (box.type === 'mdat' && moofBuffer) { const fragment = buffer_1.Buffer.concat([moofBuffer, box.header, box.data]); fragmentCount++; this.log.debug(`[${this.cameraName}] ๐Ÿ“ฆ Fragment ${fragmentCount} (moof+mdat, ${fragment.length} bytes)`, this.streamUrl); if (this.isStreamClosed(streamKey, abortSignal)) { return; } yield fragment; moofBuffer = null; } } this.log.info(`[${this.cameraName}] โœ… Recording fragments generation completed. Total fragments: ${fragmentCount}`, this.streamUrl); } catch (e) { if (this.isStreamClosed(streamKey, abortSignal) || cp.killed) { return; } this.log.error(`[${this.cameraName}] โŒ Recording fragments generation error: ${e}`, this.streamUrl); throw e; } finally { abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', abortHandler); this.log.info(`[${this.cameraName}] ๐Ÿงน Cleaning up recording session for stream ID: ${streamKey}`, this.streamUrl); if (!cp.killed) { cp.kill('SIGTERM'); setTimeout(() => { if (!cp.killed) { cp.kill('SIGKILL'); } }, 1000); } this.activeFFmpegProcesses.delete(streamKey); } } async startFFMPegFragmetedMP4Session(ffmpegPath, input) { const args = ['-hide_banner', ...input, '-f', 'mp4', '-vcodec', 'copy', '-an', '-movflags', 'frag_keyframe+empty_moov+default_base_moof+omit_tfhd_offset', 'pipe:1']; const cp = (0, child_process_1.spawn)(ffmpegPath, args, { env: process_1.env, stdio: ['pipe', 'pipe', 'pipe'] }); let intentionallyKilled = false; const originalKill = cp.kill.bind(cp); cp.kill = function (signal) { intentionallyKilled = true; return originalKill(signal); }; cp.on('exit', (code, signal) => { if (code === 0 || code === null) { this.log.info(`[${this.cameraName}] [FFmpeg] exited successfully with code ${code}, signal ${signal}`); } else if (intentionallyKilled || signal === 'SIGTERM' || signal === 'SIGKILL') { this.log.debug(`[${this.cameraName}] [FFmpeg] exited with code ${code}, signal ${signal} (intentional kill)`); } else { this.log.error(`[${this.cameraName}] [FFmpeg] exited with code ${code}, signal ${signal}`); } }); if (cp.stderr) { cp.stderr.on('data', data => { const msg = data.toString(); if (intentionallyKilled && /Immediate exit requested/i.test(msg)) { this.log.debug(`[${this.cameraName}] [FFmpeg stderr]: ${msg.trim()} (intentional stop)`); return; } if (msg.includes('moov') || msg.toLowerCase().includes('error') || msg.toLowerCase().includes('failed')) { this.log.warn(`[${this.cameraName}] [FFmpeg stderr]: ${msg.trim()}`); } }); } async function* generator() { try { while (true) { const header = await readLength(cp.stdout, 8); const length = header.readInt32BE(0) - 8; const type = header.slice(4).toString(); const data = await readLength(cp.stdout, length); yield { header, length, type, data }; } } catch (error) { if (cp.killed) { return; } throw error; } } return { cp, generator: generator() }; } toStreamKey(streamId) { return String(streamId); } isStreamClosed(streamKey, abortSignal) { return this.closedStreams.has(streamKey) || !!(abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) || this.shuttingDown; } } exports.RecordingDelegate = RecordingDelegate; //# sourceMappingURL=RecordingDelegate.js.map