node-red-contrib-homekit-bridged
Version:
Node-RED nodes to simulate Apple HomeKit devices.
478 lines (477 loc) • 19 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const logger_1 = require("@nrchkb/logger");
const util = __importStar(require("util"));
const HapNodeJS = require('hap-nodejs');
const uuid = HapNodeJS.uuid;
const StreamController = HapNodeJS.StreamController;
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const spawn = require('child_process').spawn;
const log = (0, logger_1.logger)('CameraSource');
module.exports = {
Camera: Camera,
};
function Camera(cameraControlService, config, cameraNode) {
this.configure(config, cameraControlService);
this.cameraNode = cameraNode;
}
Camera.prototype.configure = function (config, cameraControlService) {
this.name = config.name;
this.vcodec = config.cameraConfigVideoCodec;
this.videoProcessor = config.cameraConfigVideoProcessor;
this.audio = config.cameraConfigAudio;
this.acodec = config.cameraConfigAudioCodec;
this.packetsize = config.cameraConfigPacketSize;
this.fps = config.cameraConfigMaxFPS;
this.maxBitrate = config.cameraConfigMaxBitrate;
this.debug = config.cameraConfigDebug;
this.additionalCommandline = config.cameraConfigAdditionalCommandLine;
this.vflip = config.cameraConfigVerticalFlip;
this.hflip = config.cameraConfigHorizontalFlip;
this.mapvideo = config.cameraConfigMapVideo;
this.mapaudio = config.cameraConfigMapAudio;
this.videoFilter = config.cameraConfigVideoFilter;
this.uploader = config.cameraConfigSnapshotOutput;
this.interfaceName = config.cameraConfigInterfaceName;
if (!config.cameraConfigSource) {
throw new NRCHKBError('Missing source for camera.');
}
if (this.interfaceName !== '') {
if (!Object.keys(require('os').networkInterfaces()).includes(this.interfaceName)) {
throw new NRCHKBError('Incorrect interface name for camera.');
}
}
this.ffmpegSource = config.cameraConfigSource;
this.ffmpegImageSource = config.cameraConfigStillImageSource;
this.services = [];
this.streamControllers = [];
this.pendingSessions = {};
this.ongoingSessions = {};
const numberOfStreams = config.cameraConfigMaxStreams;
const videoResolutions = [];
this.maxWidth = config.cameraConfigMaxWidth;
this.maxHeight = config.cameraConfigMaxHeight;
const maxFPS = this.fps > 30 ? 30 : this.fps;
if (this.maxWidth >= 320) {
if (this.maxHeight >= 240) {
videoResolutions.push([320, 240, maxFPS]);
if (maxFPS > 15) {
videoResolutions.push([320, 240, 15]);
}
}
if (this.maxHeight >= 180) {
videoResolutions.push([320, 180, maxFPS]);
if (maxFPS > 15) {
videoResolutions.push([320, 180, 15]);
}
}
}
if (this.maxWidth >= 480) {
if (this.maxHeight >= 360) {
videoResolutions.push([480, 360, maxFPS]);
}
if (this.maxHeight >= 270) {
videoResolutions.push([480, 270, maxFPS]);
}
}
if (this.maxWidth >= 640) {
if (this.maxHeight >= 480) {
videoResolutions.push([640, 480, maxFPS]);
}
if (this.maxHeight >= 360) {
videoResolutions.push([640, 360, maxFPS]);
}
}
if (this.maxWidth >= 1280) {
if (this.maxHeight >= 960) {
videoResolutions.push([1280, 960, maxFPS]);
}
if (this.maxHeight >= 720) {
videoResolutions.push([1280, 720, maxFPS]);
}
}
if (this.maxWidth >= 1920) {
if (this.maxHeight >= 1080) {
videoResolutions.push([1920, 1080, maxFPS]);
}
}
let options = {
proxy: false,
srtp: true,
video: {
resolutions: videoResolutions,
codec: {
profiles: [0, 1, 2],
levels: [0, 1, 2],
},
},
audio: {
codecs: [
{
type: 'OPUS',
samplerate: 24,
},
{
type: 'AAC-eld',
samplerate: 16,
},
],
},
};
if (cameraControlService) {
this._createStreamControllers(cameraControlService, numberOfStreams, options);
}
else {
log.error('Camera reconfigure on the fly is not yet supported. ' +
'Please restart node-red to apply all changes to the Camera.');
}
};
Camera.prototype.handleCloseConnection = function (connectionID) {
this.streamControllers.forEach(function (controller) {
controller.handleCloseConnection(connectionID);
});
};
Camera.prototype.handleSnapshotRequest = function (request, callback) {
let resolution = request.width + 'x' + request.height;
let imageSource = this.ffmpegImageSource;
if (imageSource === undefined || !imageSource.trim()) {
imageSource = this.ffmpegSource;
}
let ffmpeg = spawn(this.videoProcessor, (imageSource + ' -t 1 -s ' + resolution + ' -f image2 -').split(' '), { env: process.env });
let imageBuffer = Buffer.alloc(0);
log.debug('Snapshot from ' + this.name + ' at ' + resolution);
log.debug('ffmpeg ' + imageSource + ' -t 1 -s ' + resolution + ' -f image2 -');
ffmpeg.stdout.on('data', function (data) {
imageBuffer = Buffer.concat([imageBuffer, data]);
});
ffmpeg.on('error', function (error) {
log.error('An error occurs while making snapshot request');
log.error(util.inspect(error));
});
ffmpeg.on('close', function (code) {
let msg;
if (this.cameraNode) {
const topic = this.cameraNode.topic
? this.cameraNode.topic
: this.cameraNode.topic_in;
msg = { payload: {}, name: this.cameraNode.name, topic: topic };
}
if (this.uploader !== 'disabled' && !this.cameraNode) {
log.error('Node required for camera to send snapshot!');
}
else if (this.uploader === 'path') {
const filePath = __dirname + '/../../cameraSnapshots/' + new Date().getTime();
log.debug('Saving camera snapshot to: ' + filePath);
const self = this;
fs.writeFile(filePath, imageBuffer, function (err) {
if (err) {
log.error('Camera snapshot file failed to save due to: ' + err);
return;
}
msg.payload['cameraSnapshot'] = filePath;
log.debug('Camera snapshot file has been saved! Sending to output... ' +
JSON.stringify(msg));
self.cameraNode.send([null, msg]);
});
}
else if (this.uploader === 'content') {
msg.payload['cameraSnapshot'] = imageBuffer;
log.debug('Sending camera snapshot buffer to output...' +
JSON.stringify(msg));
this.cameraNode.send([null, null, msg]);
}
callback(undefined, imageBuffer);
}.bind(this));
};
Camera.prototype.prepareStream = function (request, callback) {
const sessionInfo = {};
let sessionID = request['sessionID'];
sessionInfo['address'] = request['targetAddress'];
const response = {};
let videoInfo = request['video'];
if (videoInfo) {
let targetPort = videoInfo['port'];
let srtp_key = videoInfo['srtp_key'];
let srtp_salt = videoInfo['srtp_salt'];
let ssrcSource = crypto.randomBytes(4);
ssrcSource[0] = 0;
let ssrc = ssrcSource.readInt32BE(0, true);
response['video'] = {
port: targetPort,
ssrc: ssrc,
srtp_key: srtp_key,
srtp_salt: srtp_salt,
};
sessionInfo['video_port'] = targetPort;
sessionInfo['video_srtp'] = Buffer.concat([srtp_key, srtp_salt]);
sessionInfo['video_ssrc'] = ssrc;
}
let audioInfo = request['audio'];
if (audioInfo) {
let targetPort = audioInfo['port'];
let srtp_key = audioInfo['srtp_key'];
let srtp_salt = audioInfo['srtp_salt'];
let ssrcSource = crypto.randomBytes(4);
ssrcSource[0] = 0;
let ssrc = ssrcSource.readInt32BE(0, true);
response['audio'] = {
port: targetPort,
ssrc: ssrc,
srtp_key: srtp_key,
srtp_salt: srtp_salt,
};
sessionInfo['audio_port'] = targetPort;
sessionInfo['audio_srtp'] = Buffer.concat([srtp_key, srtp_salt]);
sessionInfo['audio_ssrc'] = ssrc;
}
const networkInterfaces = os.networkInterfaces();
if (this.interfaceName && networkInterfaces[this.interfaceName]) {
const currentAddress = networkInterfaces[this.interfaceName];
if (currentAddress === null || currentAddress === void 0 ? void 0 : currentAddress[0]) {
response['address'] = {
address: currentAddress[0].address,
type: currentAddress[0].family === 'IPv4' ? 'v4' : 'v6',
};
}
}
this.pendingSessions[uuid.unparse(sessionID)] = sessionInfo;
callback(response);
};
Camera.prototype.handleStreamRequest = function (request) {
const sessionID = request['sessionID'];
const requestType = request['type'];
if (sessionID) {
let sessionIdentifier = uuid.unparse(sessionID);
log.debug('Request type: ' + requestType);
if (requestType === 'start') {
const sessionInfo = this.pendingSessions[sessionIdentifier];
if (sessionInfo) {
let width = 1280;
let height = 720;
let fps = this.fps || 30;
let vbitrate = this.maxBitrate;
let abitrate = 32;
let asamplerate = 16;
const vcodec = this.vcodec;
const acodec = this.acodec;
const packetsize = this.packetsize || 1316;
const additionalCommandline = this.additionalCommandline;
const mapvideo = this.mapvideo;
const mapaudio = this.mapaudio;
let videoInfo = request['video'];
if (videoInfo) {
width = videoInfo['width'];
height = videoInfo['height'];
let expectedFPS = videoInfo['fps'];
if (expectedFPS < fps) {
fps = expectedFPS;
}
if (videoInfo['max_bit_rate'] < vbitrate) {
vbitrate = videoInfo['max_bit_rate'];
}
}
let audioInfo = request['audio'];
if (audioInfo) {
abitrate = audioInfo['max_bit_rate'];
asamplerate = audioInfo['sample_rate'];
}
let targetAddress = sessionInfo['address'];
let targetVideoPort = sessionInfo['video_port'];
let videoKey = sessionInfo['video_srtp'];
let videoSsrc = sessionInfo['video_ssrc'];
let targetAudioPort = sessionInfo['audio_port'];
let audioKey = sessionInfo['audio_srtp'];
let audioSsrc = sessionInfo['audio_ssrc'];
let vf = [];
if (this.videoFilter !== null && this.videoFilter !== '') {
vf.push(this.videoFilter);
if (this.hflip)
vf.push('hflip');
if (this.vflip)
vf.push('vflip');
}
let fcmd = this.ffmpegSource;
let ffmpegVideoArgs = ' -map ' +
mapvideo +
' -vcodec ' +
vcodec +
' -pix_fmt yuv420p' +
' -r ' +
fps +
' -f rawvideo' +
(additionalCommandline ? ' ' + additionalCommandline : '') +
(vf.length > 0 ? ' -vf ' + vf.join(',') : '') +
' -b:v ' +
vbitrate +
'k' +
' -bufsize ' +
vbitrate +
'k' +
' -maxrate ' +
vbitrate +
'k' +
' -payload_type 99';
let ffmpegVideoStream = ' -ssrc ' +
videoSsrc +
' -f rtp' +
' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' +
' -srtp_out_params ' +
videoKey.toString('base64') +
' srtp://' +
targetAddress +
':' +
targetVideoPort +
'?rtcpport=' +
targetVideoPort +
'&localrtcpport=' +
targetVideoPort +
'&pkt_size=' +
packetsize;
fcmd += ffmpegVideoArgs;
fcmd += ffmpegVideoStream;
if (this.audio) {
let ffmpegAudioArgs = ' -map ' +
mapaudio +
' -acodec ' +
acodec +
' -profile:a aac_eld' +
' -flags +global_header' +
' -f null' +
' -ar ' +
asamplerate +
'k' +
' -b:a ' +
abitrate +
'k' +
' -bufsize ' +
abitrate +
'k' +
' -ac 1' +
' -payload_type 110';
let ffmpegAudioStream = ' -ssrc ' +
audioSsrc +
' -f rtp' +
' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' +
' -srtp_out_params ' +
audioKey.toString('base64') +
' srtp://' +
targetAddress +
':' +
targetAudioPort +
'?rtcpport=' +
targetAudioPort +
'&localrtcpport=' +
targetAudioPort +
'&pkt_size=' +
packetsize;
fcmd += ffmpegAudioArgs;
fcmd += ffmpegAudioStream;
}
if (this.debug) {
fcmd += ' -loglevel debug';
}
let ffmpeg = spawn(this.videoProcessor, fcmd.split(' '), {
env: process.env,
});
log.debug('Start streaming video from ' +
this.name +
' with ' +
width +
'x' +
height +
'@' +
vbitrate +
'kBit');
log.debug('ffmpeg ' + fcmd);
ffmpeg.stderr.on('data', function (data) {
log.debug('ffmpeg data: ' + data.toString());
}.bind(this));
let self = this;
ffmpeg.on('error', function (error) {
log.error('An error occurred while making stream request');
log.error(util.inspect(error));
});
ffmpeg.on('close', (code) => {
if (code == null || code === 0 || code === 255) {
log.debug('Stopped streaming');
}
else {
log.error('ERROR: FFmpeg exited with code ' + code);
for (let i = 0; i < self.streamControllers.length; i++) {
const controller = self.streamControllers[i];
if (controller.sessionIdentifier === sessionID) {
controller.forceStop();
}
}
}
});
this.ongoingSessions[sessionIdentifier] = ffmpeg;
}
delete this.pendingSessions[sessionIdentifier];
}
else if (requestType === 'stop') {
const ffmpegProcess = this.ongoingSessions[sessionIdentifier];
if (ffmpegProcess) {
ffmpegProcess.kill('SIGTERM');
}
delete this.ongoingSessions[sessionIdentifier];
}
else if (requestType === 'reconfigure') {
}
}
};
Camera.prototype._createStreamControllers = function (cameraControlService, maxStreams, options) {
let self = this;
log.debug('Configuring services for Camera...');
self.services.push(cameraControlService);
log.debug('...added CameraControl Service');
if (self.audio) {
log.debug('...audio available');
}
else {
log.debug('...audio not available');
}
log.debug('Creating Camera Stream Controllers: ' + maxStreams + ' - Started');
log.debug('Camera options: ' + JSON.stringify(options));
for (let i = 0; i < maxStreams; i++) {
const streamController = new StreamController(i, options, self);
self.services.push(streamController.service);
self.streamControllers.push(streamController);
}
log.debug('Creating Camera Stream Controllers - Finished');
};