homebridge-shinobi
Version:
A Homebridge plugin integrating Shinobi for motion detector cameras
269 lines • 13.5 kB
JavaScript
/* eslint-disable no-case-declarations */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ShinobiStreamingDelegate = void 0;
const ip_1 = __importDefault(require("ip"));
const node_fetch_1 = __importDefault(require("node-fetch"));
const child_process_1 = require("child_process");
/**
* Shinobi Camera Streaming Delegate
*/
class ShinobiStreamingDelegate {
constructor(platform, hap, monitor, config) {
this.platform = platform;
this.hap = hap;
this.monitor = monitor;
this.config = config;
// keep track of sessions
this.pendingSessions = {};
this.ongoingSessions = {};
let shinobiConfig = this.monitor.shinobiConfig;
if (Array.isArray(shinobiConfig)) {
shinobiConfig = this.monitor.shinobiConfig[0];
}
this.platform.log.info(`creating ShinobiStreamingDelegate using shinobi config: ${JSON.stringify(shinobiConfig)}`);
this.imageSource = `${this.platform.config.shinobi_api}${shinobiConfig.snapshot}`;
// default to shinobi video source...
this.videoSource = `${this.platform.config.shinobi_api}${shinobiConfig.streams[0]}`;
const monitorDetails = JSON.parse(shinobiConfig.details);
// ...but prefer to connect directly to stream if possible
if (this.monitor.useSubStream) {
if (monitorDetails.substream && monitorDetails.substream.input && monitorDetails.substream.input.fulladdress) {
this.videoSource = monitorDetails.substream.input.fulladdress;
this.platform.log.info(`ShinobiStreamingDelegate using shinobi ' +
'substream direct camera source: ${this.videoSource}`);
}
else {
this.monitor.proxyStream = true;
this.platform.log.info(`ShinobiStreamingDelegate using shinobi dynamic substream proxy source: ${this.videoSource}`);
}
}
else {
if (monitorDetails.auto_host) {
this.videoSource = monitorDetails.auto_host;
this.platform.log.info(`ShinobiStreamingDelegate using direct camera source: ${this.videoSource}`);
}
else {
this.monitor.proxyStream = true;
this.platform.log.info(`ShinobiStreamingDelegate using shinobi proxy source: ${this.videoSource}`);
}
}
}
handleSnapshotRequest(request, callback) {
this.platform.log.debug('handleSnapshotRequest: '
+ `${this.monitor.monitorConfig.monitor_id} => ${JSON.stringify(request)} from ${this.imageSource}`);
(0, node_fetch_1.default)(this.imageSource)
.then(res => res.buffer())
.then(buffer => {
this.platform.log.debug('handleSnapshotRequest() success');
callback(undefined, buffer);
})
.catch(err => {
this.platform.log.error(`handleSnapshotRequest() error: ${err.message}`);
callback(err);
});
}
// called when iOS requests rtp setup
async prepareStream(request, callback) {
this.platform.log.debug(`prepareStream: ${this.monitor.monitorConfig.monitor_id} => ${JSON.stringify(request)}`);
const sessionId = request.sessionID;
const targetAddress = request.targetAddress;
const video = request.video;
const videoPort = video.port;
const videoSrtpKey = video.srtp_key;
const videoSrtpSalt = video.srtp_salt;
const videoSSRC = this.hap.CameraController.generateSynchronisationSource();
const sessionInfo = {
address: targetAddress,
videoPort: videoPort,
videoSRTP: Buffer.concat([videoSrtpKey, videoSrtpSalt]),
videoSSRC: videoSSRC
};
const currentAddress = ip_1.default.address('public', request.addressVersion);
const response = {
address: currentAddress,
video: {
port: videoPort,
ssrc: videoSSRC,
srtp_key: videoSrtpKey,
srtp_salt: videoSrtpSalt
}
};
this.pendingSessions[sessionId] = sessionInfo;
await this.setRequiredSubStreamState();
this.platform.log.debug('prepareStream() success');
callback(undefined, response);
}
// called when iOS device asks stream to start/stop/reconfigure
async handleStreamRequest(request, callback) {
this.platform.log.debug(`handleStreamRequest: ${JSON.stringify(request)}`);
const sessionId = request.sessionID;
switch (request.type) {
case "start" /* StreamRequestTypes.START */:
const sessionInfo = this.pendingSessions[sessionId];
if (!sessionInfo) {
const message = 'unknown sessionIdentifier: '
+ `${this.monitor.monitorConfig.monitor_id} => ${sessionId} for start request!`;
this.platform.log.warn(message);
callback(new Error(message));
return;
}
const video = request.video;
const width = video.width;
const height = video.height;
const fps = video.fps;
const payloadType = video.pt;
const maxBitrate = video.max_bit_rate;
const mtu = video.mtu;
const address = sessionInfo.address;
const videoPort = sessionInfo.videoPort;
const ssrc = sessionInfo.videoSSRC;
const videoSRTP = sessionInfo.videoSRTP.toString('base64');
this.platform.log.debug(`requested video stream: ${width}x${height}, ${fps} fps, ${maxBitrate} kbps, ${mtu} mtu`);
const ffmpegInputArgs = this.config.ffmpeg_input_args || '-fflags +genpts';
const ffmpegProcessArgs = this.config.ffmpeg_process_args || '-vsync drop -vcodec copy -an';
let ffmpegCommand = `${ffmpegInputArgs} -i ${this.videoSource} ${ffmpegProcessArgs} `
+ `-f rtp -payload_type ${payloadType} -ssrc ${ssrc}`;
ffmpegCommand += ` -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params ${videoSRTP}`;
ffmpegCommand += ` srtp://${address}:${videoPort}`
+ `?rtcpport=${videoPort}&localrtcpport=${videoPort}&pkt_size=${mtu}`;
this.platform.log.debug(ffmpegCommand);
let started = false;
const ffmpegProcess = (0, child_process_1.spawn)('ffmpeg', ffmpegCommand.split(' '), { env: process.env });
ffmpegProcess.stderr.on('data', () => {
if (!started) {
started = true;
this.platform.log.debug('ffmpeg received first frame');
// do not forget to execute callback once set up
callback();
}
});
ffmpegProcess.on('error', error => {
this.platform.log.error(`failed to start video stream: ${error.message}`);
callback(new Error('ffmpeg process creation failed!'));
});
ffmpegProcess.on('exit', (code, signal) => {
const message = `ffmpeg exited with code: ${code} and signal: ${signal}`;
if (code === null || code === 255) {
this.platform.log.debug(`${message} (Video stream stopped!)`);
}
else {
this.platform.log.error(`${message} (error)`);
if (!started) {
callback(new Error(message));
}
else {
this.controller.forceStopStreamingSession(sessionId);
}
}
});
this.ongoingSessions[sessionId] = ffmpegProcess;
delete this.pendingSessions[sessionId];
this.platform.log.debug('handleStreamRequest() START success');
break;
case "reconfigure" /* StreamRequestTypes.RECONFIGURE */:
// not supported
this.platform.log.warn(`received (unsupported) request to reconfigure to: ${JSON.stringify(request.video)}`);
callback();
break;
case "stop" /* StreamRequestTypes.STOP */:
const existingFfmpegProcess = this.ongoingSessions[sessionId];
if (!existingFfmpegProcess) {
const message = `unknown sessionIdentifier: ${this.monitor.monitorConfig.monitor_id} => ${sessionId} for stop request!`;
this.platform.log.warn(message);
callback(new Error(message));
return;
}
this.platform.log.debug(`killing: ${this.monitor.monitorConfig.monitor_id} `
+ `=> ${sessionId} => PID: ${existingFfmpegProcess.pid}`);
try {
if (existingFfmpegProcess) {
existingFfmpegProcess.kill('SIGKILL');
}
}
catch (e) {
this.platform.log.error('error occurred terminating the video process! ' + e);
}
delete this.ongoingSessions[sessionId];
this.platform.log.debug('stopped streaming session!');
await this.setRequiredSubStreamState();
callback();
break;
}
}
// called when Homebridge is shutting down
async shutdown() {
Object.keys(this.ongoingSessions).forEach((sessionId) => {
const ffmpegProcess = this.ongoingSessions[sessionId];
this.platform.log.debug(`killing: ${this.monitor.monitorConfig.monitor_id} => ${sessionId} => PID: ${ffmpegProcess.pid}`);
try {
if (ffmpegProcess) {
ffmpegProcess.kill('SIGKILL');
}
}
catch (e) {
this.platform.log.error('error occurred terminating the video process! ' + e);
}
});
this.ongoingSessions = {};
this.pendingSessions = {};
await this.setRequiredSubStreamState();
}
shouldSubStreamBeActive() {
const ongoingSessionsSessionsExist = (Object.keys(this.ongoingSessions).length > 0);
const pendingSessionsExist = (Object.keys(this.pendingSessions).length > 0);
return ongoingSessionsSessionsExist || pendingSessionsExist;
}
getSubStreamIsActive() {
const url = `${this.platform.config.shinobi_api}/${this.platform.config.api_key}/monitor/` +
`${this.platform.config.group_key}/${this.monitor.monitorConfig.monitor_id}`;
this.platform.log.debug(`fetching from Shinobi API: ${url}`);
return (0, node_fetch_1.default)(url)
.then(res => res.json())
.then(shinobiConfig => {
if (Array.isArray(shinobiConfig)) {
shinobiConfig = shinobiConfig[0];
}
return shinobiConfig.subStreamActive;
})
.catch(err => {
this.platform.log.error(`getSubStreamIsActive() error: ${err.message}`);
});
}
toggleSubStreamActive(subStreamShouldBeActive) {
const url = `${this.platform.config.shinobi_api}/${this.platform.config.api_key}/toggleSubstream/` +
`${this.platform.config.group_key}/${this.monitor.monitorConfig.monitor_id}`;
this.platform.log.debug(`fetching from Shinobi API: ${url}`);
return (0, node_fetch_1.default)(url)
.then(res => res.json())
.then(shinobiResponse => {
this.platform.log.debug(`substream toggle response: ${JSON.stringify(shinobiResponse)}`);
if (shinobiResponse.ok !== subStreamShouldBeActive) {
throw Error(`Unable to set substream active state: ${subStreamShouldBeActive}, ` +
`response: ${JSON.stringify(shinobiResponse)}`);
}
})
.catch(err => {
this.platform.log.error(`toggleSubStreamActive() error: ${err.message}`);
});
}
// do nothing if proxy dynamic sub-stream usage is not in use, otherwise if at least one pending or ongoing
// session exists then ensure the sub-stream state in shinobi is active, otherwise ensure it is not active
async setRequiredSubStreamState() {
if (!(this.monitor.useSubStream && this.monitor.proxyStream)) {
return;
}
const subStreamShouldBeActive = this.shouldSubStreamBeActive();
this.platform.log.debug(`subStreamShouldBeActive: ${subStreamShouldBeActive}`);
const subStreamIsActive = await this.getSubStreamIsActive();
this.platform.log.debug(`subStreamIsActive: ${subStreamIsActive}`);
if (subStreamIsActive !== subStreamShouldBeActive) {
await this.toggleSubStreamActive(subStreamShouldBeActive);
}
}
}
exports.ShinobiStreamingDelegate = ShinobiStreamingDelegate;
//# sourceMappingURL=shinobiStreamingDelegate.js.map
;