UNPKG

@palekseii/homebridge-tuya-platform

Version:

Fork version of official Tuya Homebridge plugin. Brings a bunch of bug fix and new device support.

330 lines 14.4 kB
"use strict"; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable max-len */ Object.defineProperty(exports, "__esModule", { value: true }); exports.TuyaStreamingDelegate = void 0; 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"); /* interface SampleRateEntry { type: AudioRecordingCodecType; bitrateMode: number; samplerate: AudioRecordingSamplerate[]; audioChannels: number; } */ class TuyaStreamingDelegate { constructor(camera) { this.pendingSessions = {}; this.ongoingSessions = {}; this.camera = camera; this.hap = camera.platform.api.hap; // this.recordingDelegate = new TuyaRecordingDelegate(); const resolutions = [ [320, 180, 30], [320, 240, 15], [320, 240, 30], [480, 270, 30], [480, 360, 30], [640, 360, 30], [640, 480, 30], [1280, 720, 30], [1280, 960, 30], [1920, 1080, 30], [1600, 1200, 30], ]; const streamingOptions = { supportedCryptoSuites: [0 /* SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80 */], video: { codec: { profiles: [0 /* H264Profile.BASELINE */, 1 /* H264Profile.MAIN */, 2 /* H264Profile.HIGH */], levels: [0 /* H264Level.LEVEL3_1 */, 1 /* H264Level.LEVEL3_2 */, 2 /* H264Level.LEVEL4_0 */], }, resolutions: resolutions, }, audio: { twoWayAudio: false, codecs: [ { type: "AAC-eld" /* AudioStreamingCodecType.AAC_ELD */, samplerate: 16 /* AudioStreamingSamplerate.KHZ_16 */, }, ], }, }; const recordingOptions = { overrideEventTriggerOptions: [ 1 /* EventTriggerOption.MOTION */, 2 /* EventTriggerOption.DOORBELL */, ], prebufferLength: 4 * 1000, mediaContainerConfiguration: [ { type: 0 /* MediaContainerType.FRAGMENTED_MP4 */, fragmentLength: 4000, }, ], video: { parameters: { profiles: [ 0 /* H264Profile.BASELINE */, 1 /* H264Profile.MAIN */, 2 /* H264Profile.HIGH */, ], levels: [ 0 /* H264Level.LEVEL3_1 */, 1 /* H264Level.LEVEL3_2 */, 2 /* H264Level.LEVEL4_0 */, ], }, resolutions: resolutions, type: 0 /* this.hap.VideoCodecType.H264 */, }, audio: { codecs: [ { samplerate: 3 /* this.hap.AudioRecordingSamplerate.KHZ_32 */, type: 0 /* this.hap.AudioRecordingCodecType.AAC_LC */, }, ], }, }; const options = { delegate: this, streamingOptions: streamingOptions, // recording: { // options: recordingOptions, // delegate: this.recordingDelegate // } }; this.controller = new this.hap.CameraController(options); } stopStream(sessionId) { var _a, _b, _c; 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.camera.log.error(`Error occurred closing socket: ${error}`); } try { (_b = session.mainProcess) === null || _b === void 0 ? void 0 : _b.stop(); } catch (error) { this.camera.log.error(`Error occurred terminating main FFmpeg process: ${error}`); } try { (_c = session.returnProcess) === null || _c === void 0 ? void 0 : _c.stop(); } catch (error) { this.camera.log.error(`Error occurred terminating two-way FFmpeg process: ${error}`); } delete this.ongoingSessions[sessionId]; this.camera.log.info('Stopped video stream.'); } } forceStopStream(sessionId) { this.controller.forceStopStreamingSession(sessionId); } async handleSnapshotRequest(request, callback) { try { this.camera.log.debug(`Snapshot requested: ${request.width} x ${request.height}`); const snapshot = await this.fetchSnapshot(); this.camera.log.debug('Sending snapshot'); callback(undefined, snapshot); } catch (error) { callback(error); } } async prepareStream(request, callback) { const videoIncomingPort = await (0, camera_utils_1.reservePorts)({ count: 1, }); const videoSSRC = this.hap.CameraController.generateSynchronisationSource(); const audioIncomingPort = await (0, camera_utils_1.reservePorts)({ count: 1, }); const audioSSRC = this.hap.CameraController.generateSynchronisationSource(); 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]), audioSSRC: audioSSRC, audioIncomingPort: audioIncomingPort[0], 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.hap.StreamRequestTypes.START */: { this.camera.log.debug(`Start stream requested: ${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.hap.StreamRequestTypes.RECONFIGURE */: { this.camera.log.debug(`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.hap.StreamRequestTypes.STOP */: { this.camera.log.debug('Stop stream requested'); this.stopStream(request.sessionID); callback(); break; } } } async retrieveDeviceRTSP() { const data = await this.camera.deviceManager.api.post(`/v1.0/devices/${this.camera.device.id}/stream/actions/allocate`, { type: 'rtsp', }); return data.result.url; } async startStream(request, callback) { const sessionInfo = this.pendingSessions[request.sessionID]; if (!sessionInfo) { this.camera.log.error('Error finding session information.'); callback(new Error('Error finding session information')); } const vcodec = 'libx264'; const mtu = 1316; // request.video.mtu is not used const fps = request.video.fps; const videoBitrate = request.video.max_bit_rate; const rtspUrl = await this.retrieveDeviceRTSP(); const ffmpegArgs = [ '-hide_banner', '-loglevel', 'verbose', '-i', rtspUrl, '-an', '-sn', '-dn', '-r', fps.toString(), '-codec:v', vcodec, '-pix_fmt', 'yuv420p', '-color_range', 'mpeg', '-f', 'rawvideo', ]; const encoderOptions = '-preset ultrafast -tune zerolatency'; if (encoderOptions) { ffmpegArgs.push(...encoderOptions.split(/\s+/)); } if (videoBitrate > 0) { ffmpegArgs.push('-b:v', `${videoBitrate}k`); } // Video Stream ffmpegArgs.push('-payload_type', `${request.video.pt}`, '-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}`); // Setting up audio if (request.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */ || request.audio.codec === "AAC-eld" /* AudioStreamingCodecType.AAC_ELD */) { ffmpegArgs.push('-vn', '-sn', '-dn'); if (request.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */) { ffmpegArgs.push('-acodec', 'libopus', '-application', 'lowdelay'); } else { ffmpegArgs.push('-acodec', 'libfdk_aac', '-profile:a', 'aac_eld'); } ffmpegArgs.push('-flags', '+global_header', '-f', 'null', '-ar', `${request.audio.sample_rate}k`, '-b:a', `${request.audio.max_bit_rate}k`, '-ac', `${request.audio.channel}`, '-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`); } else { this.camera.log.error(`Unsupported audio codec requested: ${request.audio.codec}`); } ffmpegArgs.push('-progress', 'pipe:1'); const activeSession = {}; activeSession.socket = (0, dgram_1.createSocket)(sessionInfo.addressVersion === 'ipv6' ? 'udp6' : 'udp4'); activeSession.socket.on('error', (err) => { this.camera.log.error('Socket error: ' + err.message); this.stopStream(request.sessionID); }); activeSession.socket.on('message', () => { if (activeSession.timeout) { clearTimeout(activeSession.timeout); } activeSession.timeout = setTimeout(() => { this.camera.log.info('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(request.sessionID, camera_utils_1.defaultFfmpegPath, ffmpegArgs, this.camera.log, this, callback); this.ongoingSessions[request.sessionID] = activeSession; delete this.pendingSessions[request.sessionID]; } async fetchSnapshot() { if (!this.camera.device.online) { this.camera.log.debug('Device is currently offline.'); throw new Error('Device is currently offline.'); } // TODO: Check if there is a stream already running to fetch snapshot. const rtspUrl = await this.retrieveDeviceRTSP(); const ffmpegArgs = [ '-i', rtspUrl, '-frames:v', '1', '-hide_banner', '-loglevel', 'error', '-f', 'image2', '-', ]; return new Promise((resolve, reject) => { this.camera.log.debug(`Running Snapshot command: ${camera_utils_1.defaultFfmpegPath} ${ffmpegArgs.map(value => JSON.stringify(value)).join(' ')}`); const ffmpeg = (0, child_process_1.spawn)(camera_utils_1.defaultFfmpegPath, ffmpegArgs.map(x => x.toString()), { env: process.env }); let errors = []; let snapshotBuffer = Buffer.alloc(0); ffmpeg.stdout.on('data', (data) => { snapshotBuffer = Buffer.concat([snapshotBuffer, data]); }); ffmpeg.on('error', (error) => { this.camera.log.error(`FFmpeg process creation failed: ${error.message} - Showing "offline" image instead.`); reject('Failed to fetch snapshot.'); }); ffmpeg.stderr.on('data', (data) => { errors = errors.slice(-5); errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' ')); }); ffmpeg.on('close', () => { if (snapshotBuffer.length > 0) { resolve(snapshotBuffer); } else { this.camera.log.error('Failed to fetch snapshot. Showing "offline" image instead.'); if (errors.length > 0) { this.camera.log.error(errors.join(' - ')); } reject('Unable to fetch snapshot.'); } }); }); } } exports.TuyaStreamingDelegate = TuyaStreamingDelegate; //# sourceMappingURL=TuyaStreamDelegate.js.map