UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

378 lines (330 loc) 14.8 kB
// streamer // Part of homebridge-nest-accfactory // // This is the base class for all Camera/Doorbell streaming // // Buffers a single audio/video stream which allows multiple HomeKit devices to connect to the single stream // for live viewing and/or recording // // The following functions should be overriden in your class which extends this // // streamer.connect() // streamer.close() // streamer.talkingAudio(talkingData) // streamer.update(deviceData) <- call super after // // The following defines should be overriden in your class which extends this // // blankAudio - Buffer containing a blank audio segment for the type of audio being used // // Code version 2025/03/16 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setInterval, setTimeout, clearTimeout } from 'node:timers'; import fs from 'fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Define constants const CAMERAOFFLINEH264FILE = 'Nest_camera_offline.h264'; // Camera offline H264 frame file const CAMERAOFFH264FILE = 'Nest_camera_off.h264'; // Camera off H264 frame file const CAMERATRANSFERJPGFILE = 'Nest_camera_transfer.jpg'; // Camera transferring H264 frame file const TALKBACKAUDIOTIMEOUT = 1000; const H264NALSTARTCODE = Buffer.from([0x00, 0x00, 0x00, 0x01]); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname // Streamer object export default class Streamer { videoEnabled = undefined; // Video stream on camera enabled or not audioEnabled = undefined; // Audio from camera enabled or not online = undefined; // Camera online or not uuid = undefined; // UUID of the device connecting connected = undefined; // Stream endpoint connection: undefined = not connected , false = connecting , true = connected and streaming blankAudio = undefined; // Blank audio 'frame' codecs = { video: undefined, // Video codec being used audio: undefined, // Audio codec being used talk: undefined, // Talking codec being used }; // Internal data only for this class #outputTimer = undefined; // Timer for non-blocking loop to stream output data #outputs = {}; // Output streams ie: buffer, live, record #cameraOfflineFrame = undefined; // Camera offline video frame #cameraVideoOffFrame = undefined; // Video turned off on camera video frame #cameraTransferringFrame = undefined; // Camera transferring between Nest/Google Home video frame constructor(deviceData, options) { // Setup logger object if passed as option if ( typeof options?.log?.info === 'function' && typeof options?.log?.success === 'function' && typeof options?.log?.warn === 'function' && typeof options?.log?.error === 'function' && typeof options?.log?.debug === 'function' ) { this.log = options.log; } // Store data we need from the device data passed it this.migrating = deviceData?.migrating === true; this.online = deviceData?.online === true; this.videoEnabled = deviceData?.streaming_enabled === true; this.audioEnabled = deviceData?.audio_enabled === true; this.uuid = deviceData?.nest_google_uuid; // Setup location for *.h264 frame files. This can be overriden by a passed in option let resourcePath = path.resolve(__dirname + '/res'); // Default location for *.h264 files if ( typeof options?.resourcePath === 'string' && options.resourcePath !== '' && fs.existsSync(path.resolve(options.resourcePath)) === true ) { resourcePath = path.resolve(options.resourcePath); } // load buffer for camera offline in .h264 frame if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)) === true) { this.#cameraOfflineFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFLINEH264FILE)); } // load buffer for camera stream off in .h264 frame if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)) === true) { this.#cameraVideoOffFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERAOFFH264FILE)); } // load buffer for camera transferring in .h264 frame if (fs.existsSync(path.resolve(resourcePath + '/' + CAMERATRANSFERJPGFILE)) === true) { this.#cameraTransferringFrame = fs.readFileSync(path.resolve(resourcePath + '/' + CAMERATRANSFERJPGFILE)); } // Start a non-blocking loop for output to the various streams which connect to our streamer object // This process will also handle the rolling-buffer size we require // Record streams will always start from the beginning of the buffer (tail) // Live streams will always start from the end of the buffer (head) let lastTimeVideo = Date.now(); this.#outputTimer = setInterval(() => { let dateNow = Date.now(); let outputVideoFrame = dateNow > lastTimeVideo + 90000 / 30; // 30fps Object.values(this.#outputs).forEach((output) => { // Monitor for camera going offline, video enabled/disabled or being transferred between Nest/Google Home // We'll insert the appropriate video frame into the stream if (this.online === false && this.#cameraOfflineFrame !== undefined && outputVideoFrame === true) { // Camera is offline so feed in our custom h264 frame and AAC silence output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraOfflineFrame }); output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio }); lastTimeVideo = dateNow; } if (this.online === true && this.videoEnabled === false && this.#cameraVideoOffFrame !== undefined && outputVideoFrame === true) { // Camera video is turned off so feed in our custom h264 frame and AAC silence output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraVideoOffFrame }); output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio }); lastTimeVideo = dateNow; } if (this.migrating === true && this.#cameraTransferringFrame !== undefined && outputVideoFrame === true) { // Camera video is turned off so feed in our custom h264 frame and AAC silence output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraTransferringFrame }); output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio }); lastTimeVideo = dateNow; } // Keep our 'main' rolling buffer under a certain size // Live/record buffers will always reduce in length in the next section // <---- maybe make this time based x time since first packet in buffer? if (output.type === 'buffer' && output.buffer.length > 1250) { output.buffer.shift(); } // Output the packet data to any 'live' or 'recording' streams if (output.type === 'live' || output.type === 'record') { let packet = output.buffer.shift(); if (packet?.type === 'video' && typeof output?.video?.write === 'function') { packet.data = Buffer.concat([H264NALSTARTCODE, packet.data]); output.video.write(packet.data); } if (packet?.type === 'audio' && typeof output?.audio?.write === 'function') { output.audio.write(packet.data); } } }); }, 0); } // Class functions isBuffering() { return this.#outputs?.buffer !== undefined; } startBuffering() { if (this.#outputs?.buffer === undefined) { // No active buffer session, start connection to streamer if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') { this?.log?.debug && this.log.debug('Started buffering for uuid "%s"', this.uuid); this.connect(); } this.#outputs.buffer = { type: 'buffer', buffer: [], }; } } startLiveStream(sessionID, videoStream, audioStream, talkbackStream) { // Setup error catching for video/audio/talkback streams if (videoStream !== null && typeof videoStream === 'object') { videoStream.on('error', () => { // EPIPE errors?? }); } if (audioStream !== null && typeof audioStream === 'object') { audioStream.on('error', () => { // EPIPE errors?? }); } if (talkbackStream !== null && typeof talkbackStream === 'object') { let talkbackTimeout = undefined; talkbackStream.on('error', () => { // EPIPE errors?? }); talkbackStream.on('data', (data) => { // Received audio data to send onto camera/doorbell for output if (typeof this.talkingAudio === 'function') { this.talkingAudio(data); clearTimeout(talkbackTimeout); talkbackTimeout = setTimeout(() => { // no audio received in 1000ms, so mark end of stream this.talkingAudio(Buffer.alloc(0)); }, TALKBACKAUDIOTIMEOUT); } }); } if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') { // We do not have an active connection, so startup connection this.connect(); } // Add video/audio streams for our output loop to handle outputting this.#outputs[sessionID] = { type: 'live', video: videoStream, audio: audioStream, talk: talkbackStream, buffer: [], }; // finally, we've started live stream this?.log?.debug && this.log.debug( 'Started live stream from uuid "%s" %s "%s"', this.uuid, talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio and sesssion id of' : 'and sesssion id of', sessionID, ); } startRecordStream(sessionID, videoStream, audioStream) { // Setup error catching for video/audio streams if (videoStream !== null && typeof videoStream === 'object') { videoStream.on('error', () => { // EPIPE errors?? }); } if (audioStream !== null && typeof audioStream === 'object') { audioStream.on('error', () => { // EPIPE errors?? }); } if (this.connected === undefined && typeof this.connect === 'function' && this.online === true && this.videoEnabled === true) { // We do not have an active connection, so startup connection this.connect(); } // Add video/audio streams for our output loop to handle outputting this.#outputs[sessionID] = { type: 'record', video: videoStream, audio: audioStream, // eslint-disable-next-line no-undef buffer: this.#outputs?.buffer?.buffer !== undefined ? structuredClone(this.#outputs.buffer.buffer) : [], }; // Finally we've started the recording stream this?.log?.debug && this.log.debug('Started recording stream from uuid "%s" with sesison id of "%s"', this.uuid, sessionID); } stopRecordStream(sessionID) { // Request to stop a recording stream if (this.#outputs?.[sessionID] !== undefined) { this?.log?.debug && this.log.debug('Stopped recording stream from uuid "%s"', this.uuid); delete this.#outputs[sessionID]; } // If we have no more output streams active, we'll close the connection if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { this.close(); } } stopLiveStream(sessionID) { // Request to stop an active live stream if (this.#outputs?.[sessionID] !== undefined) { this?.log?.debug && this.log.debug('Stopped live stream from uuid "%s"', this.uuid); delete this.#outputs[sessionID]; } // If we have no more output streams active, we'll close the connection if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { this.close(); } } stopBuffering() { if (this.#outputs?.buffer !== undefined) { this?.log?.debug && this.log.debug('Stopped buffering from uuid "%s"', this.uuid); delete this.#outputs.buffer; } // If we have no more output streams active, we'll close the connection if (Object.keys(this.#outputs).length === 0 && typeof this.close === 'function') { this.close(); } } stopEverything() { if (Object.keys(this.#outputs).length > 0) { this?.log?.debug && this.log.debug('Stopped buffering, live and recording from uuid "%s"', this.uuid); this.#outputs = {}; // No more outputs if (typeof this.close === 'function') { this.close(); } } } update(deviceData) { if (typeof deviceData !== 'object') { return; } this.migrating = deviceData.migrating; if (this.uuid !== deviceData?.nest_google_uuid) { this.uuid = deviceData?.nest_google_uuid; if (Object.keys(this.#outputs).length > 0) { // Since the uuid has change and a streamer may use this, if there any any active outpuyts, close and connect again if (typeof this.close === 'function') { this.close(); } if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') { this.connect(); } } } if ( this.online !== deviceData.online || this.videoEnabled !== deviceData.streaming_enabled || this.audioEnabled !== deviceData?.audio_enabled ) { // Online status or streaming status has changed has changed this.online = deviceData?.online === true; this.videoEnabled = deviceData?.streaming_enabled === true; this.audioEnabled = deviceData?.audio_enabled === true; if ((this.online === false || this.videoEnabled === false || this.audioEnabled === false) && typeof this.close === 'function') { this.close(); // as offline or streaming not enabled, close streamer } if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') { this.connect(); // Connect for stream } } } addToOutput(type, data) { if (typeof type !== 'string' || type === '' || Buffer.isBuffer(data) === false) { return; } if (data.indexOf(H264NALSTARTCODE) === 0) { // Strip H264 start code from input buffer. We'll handle this later data = data.subarray(H264NALSTARTCODE.length); } Object.values(this.#outputs).forEach((output) => { output.buffer.push({ time: Date.now(), // Timestamp of when this was added to buffer type: type, data: data, }); }); } haveOutputs() { return Object.keys(this.#outputs).length > 0; } }