homebridge-nest-accfactory
Version:
Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras
378 lines (331 loc) • 14 kB
JavaScript
// 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.06.15
// 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 CAMERA_RESOURCE = {
OFFLINE: 'Nest_camera_offline.h264',
OFF: 'Nest_camera_off.h264',
TRANSFER: 'Nest_camera_transfer.h264',
};
const TALKBACK_AUDIO_TIMEOUT = 1000;
const H264_NAL_START_CODE = Buffer.from([0x00, 0x00, 0x00, 0x01]);
const MAX_BUFFER_AGE = 5000; // Keep last 5s of media in buffer
const STREAM_FRAME_INTERVAL = 1000 / 30; // 30fps approx
const RESOURCE_PATH = './res';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
const LOG_LEVELS = {
INFO: 'info',
SUCCESS: 'success',
WARN: 'warn',
ERROR: 'error',
DEBUG: 'debug',
};
// Streamer object
export default class Streamer {
log = undefined; // Logging function object
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 (Object.values(LOG_LEVELS).every((fn) => typeof options?.log?.[fn] === '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;
// Load support video frame files as required
const loadFrameIfExists = (filename, label) => {
let buffer = undefined;
let file = path.resolve(__dirname, RESOURCE_PATH, filename);
if (fs.existsSync(file) === true) {
buffer = fs.readFileSync(file);
} else {
this.log?.warn?.('Failed to load %s video resource for "%s"', label, deviceData.description);
}
return buffer;
};
this.#cameraOfflineFrame = loadFrameIfExists(CAMERA_RESOURCE.OFFLINE, 'offline');
this.#cameraVideoOffFrame = loadFrameIfExists(CAMERA_RESOURCE.OFF, 'video off');
this.#cameraTransferringFrame = loadFrameIfExists(CAMERA_RESOURCE.TRANSFER, 'transferring');
// 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 lastVideoFrameTime = Date.now();
this.#outputTimer = setInterval(() => {
let dateNow = Date.now();
let outputVideoFrame = dateNow - lastVideoFrameTime >= STREAM_FRAME_INTERVAL;
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 });
lastVideoFrameTime = 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 });
lastVideoFrameTime = 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 });
lastVideoFrameTime = dateNow;
}
// Keep our 'main' rolling buffer under a certain size
// Live/record buffers will always reduce in length in the next section
if (output.type === 'buffer') {
let cutoffTime = dateNow - MAX_BUFFER_AGE;
while (output.buffer.length > 0 && output.buffer[0].time < cutoffTime) {
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([H264_NAL_START_CODE, packet.data]);
output.video.write(packet.data);
}
if (packet?.type === 'audio' && typeof output?.audio?.write === 'function') {
output.audio.write(packet.data);
}
}
});
}, 10); // Every 10ms, rather than "next tick"
}
// 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?.('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));
}, TALKBACK_AUDIO_TIMEOUT);
}
});
}
// We do not have an active connection, so startup connection
this.#doConnect();
// 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?.(
'Started live stream from uuid "%s" %s "%s"',
this.uuid,
talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio and session id of' : 'and session 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??
});
}
// We do not have an active connection, so startup connection
this.#doConnect();
// 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?.('Started recording stream from uuid "%s" with session id of "%s"', this.uuid, sessionID);
}
stopRecordStream(sessionID) {
// Request to stop a recording stream
if (this.#outputs?.[sessionID] !== undefined) {
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 (this.haveOutputs() === false && typeof this.close === 'function') {
this.close();
}
}
stopLiveStream(sessionID) {
// Request to stop an active live stream
if (this.#outputs?.[sessionID] !== undefined) {
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 (this.haveOutputs() === false && typeof this.close === 'function') {
this.close();
}
}
stopBuffering() {
if (this.#outputs?.buffer !== undefined) {
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 (this.haveOutputs() === false && typeof this.close === 'function') {
this.close();
}
}
stopEverything() {
if (this.haveOutputs() === true) {
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 (this.haveOutputs() === true) {
// Since the uuid has change and a streamer may use this, if there any any active outputs, close and connect again
if (typeof this.close === 'function') {
this.close();
}
this.#doConnect();
}
}
if (
this.online !== deviceData.online ||
this.videoEnabled !== deviceData.streaming_enabled ||
this.audioEnabled !== deviceData?.audio_enabled
) {
// Online status or streaming status has changed
this.online = deviceData?.online === true;
this.videoEnabled = deviceData?.streaming_enabled === true;
this.audioEnabled = deviceData?.audio_enabled === true;
if (this.haveOutputs() === true) {
// Since online, video, audio enabled status has changed, if there any any active outputs, close and connect again
if ((this.online === false || this.videoEnabled === false || this.audioEnabled === false) && typeof this.close === 'function') {
this.close(); // as offline or streaming not enabled, close streamer
}
this.#doConnect();
}
}
}
addToOutput(type, data) {
if (typeof type !== 'string' || type === '' || Buffer.isBuffer(data) === false) {
return;
}
if (data.indexOf(H264_NAL_START_CODE) === 0) {
// Strip H264 start code from input buffer. We'll handle this later
data = data.subarray(H264_NAL_START_CODE.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;
}
#doConnect() {
if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') {
this.connect();
}
}
}