Files
scrypted/plugins/onvif/src/main.ts
2023-02-23 19:24:44 -08:00

603 lines
22 KiB
TypeScript

import sdk, { AdoptDevice, Device, DeviceCreatorSettings, DeviceDiscovery, DeviceInformation, DiscoveredDevice, Intercom, MediaObject, MediaStreamOptions, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, PictureOptions, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting, Settings, SettingValue, VideoCamera, VideoCameraConfiguration } from "@scrypted/sdk";
import onvif from 'onvif';
import { Stream } from "stream";
import xml2js from 'xml2js';
import { Destroyable, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
import { connectCameraAPI, OnvifCameraAPI, OnvifEvent } from "./onvif-api";
import { OnvifIntercom } from "./onvif-intercom";
const { mediaManager, systemManager, deviceManager } = sdk;
function computeInterval(fps: number, govLength: number) {
if (!fps || !govLength)
return;
return govLength / fps * 1000;
}
function computeBitrate(bitrate: number) {
if (!bitrate)
return;
return bitrate * 1000;
}
function convertAudioCodec(codec: string) {
if (codec?.toLowerCase()?.includes('mp4a'))
return 'aac';
if (codec?.toLowerCase()?.includes('aac'))
return 'aac';
return codec?.toLowerCase();
}
class OnvifCamera extends RtspSmartCamera implements ObjectDetector, Intercom, VideoCameraConfiguration {
eventStream: Stream;
client: OnvifCameraAPI;
rtspMediaStreamOptions: Promise<UrlMediaStreamOptions[]>;
intercom = new OnvifIntercom(this);
constructor(nativeId: string, provider: RtspProvider) {
super(nativeId, provider);
this.updateManagementUrl();
this.updateDevice();
}
async setVideoStreamOptions(options: MediaStreamOptions): Promise<void> {
const client = await this.getClient();
const profiles: any[] = await client.getProfiles();
const profile = profiles.find(profile => profile.$.token === options.id);
const configuration = profile.videoEncoderConfiguration;
const videoOptions = options.video;
switch (videoOptions.codec) {
case 'h264':
configuration.encoding = 'H264';
if (videoOptions.idrIntervalMillis && videoOptions.fps) {
configuration.H264 ||= {};
configuration.H264.govLength = Math.floor(videoOptions.fps * videoOptions.idrIntervalMillis / 1000);
}
if (videoOptions.keyframeInterval) {
configuration.H264 ||= {};
configuration.H264.govLength = videoOptions.keyframeInterval;
}
if (videoOptions.profile) {
configuration.H264 ||= {};
configuration.H264.profile = videoOptions.profile;
}
break;
}
if (videoOptions.width && videoOptions.height) {
configuration.resolution ||= {};
configuration.resolution.width = videoOptions.width;
configuration.resolution.height = videoOptions.height;
}
if (videoOptions?.bitrate) {
configuration.rateControl ||= {};
configuration.rateControl.bitrateLimit = Math.floor(videoOptions.bitrate / 1000);
}
if (videoOptions.fps) {
configuration.rateControl ||= {};
configuration.rateControl.frameRateLimit = videoOptions.fps;
configuration.rateControl.encodingInterval = 1;
}
return new Promise((r, f) => {
client.cam.setVideoEncoderConfiguration(configuration, (e: Error, result: any) => {
if (e)
return f(e);
r();
})
});
}
updateManagementUrl() {
const ip = this.storage.getItem('ip');
if (!ip)
return;
const info = this.info || {};
const managementUrl = `http://${ip}`;
if (info.managementUrl !== managementUrl) {
info.managementUrl = managementUrl;
this.info = info;
}
}
getDetectionInput(detectionId: any, eventId?: any): Promise<MediaObject> {
throw new Error("Method not implemented.");
}
async getObjectTypes(): Promise<ObjectDetectionTypes> {
const client = await this.getClient();
const classes = await client.getEventTypes();
return {
classes,
}
}
async getPictureOptions(): Promise<PictureOptions[]> {
try {
const vsos = await this.getVideoStreamOptions();
const ret = vsos.map(({ id, name, video }) => ({
id,
name,
// onvif doesn't actually specify the snapshot dimensions for a profile.
// it may just send whatever.
picture: {
width: video?.width,
height: video?.height,
}
}));
return ret;
}
catch (e) {
}
}
async takeSmartCameraPicture(options?: PictureOptions): Promise<MediaObject> {
const client = await this.getClient();
let snapshot: Buffer;
let id = options?.id;
if (!id) {
const vsos = await this.getVideoStreamOptions();
const vso = this.getDefaultStream(vsos);
id = vso?.id;
}
snapshot = await client.jpegSnapshot(id);
// it is possible that onvif does not support snapshots, in which case return the video stream
if (!snapshot) {
// grab the real device rather than the using this.getVideoStream
// so we can take advantage of the rebroadcast plugin if available.
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
return realDevice.getVideoStream({
id,
});
// todo: this is bad. just disable camera interface altogether.
}
return mediaManager.createMediaObject(snapshot, 'image/jpeg');
}
async getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
if (!this.rtspMediaStreamOptions) {
this.rtspMediaStreamOptions = new Promise(async (resolve) => {
try {
const client = await this.getClient();
const profiles: any[] = await client.getProfiles();
const ret: UrlMediaStreamOptions[] = [];
for (const { $, name, videoEncoderConfiguration, audioEncoderConfiguration } of profiles) {
try {
ret.push({
id: $.token,
metadata: {
videoId: videoEncoderConfiguration?.$?.token,
audioId: audioEncoderConfiguration?.$?.token,
},
name: name,
container: 'rtsp',
url: await client.getStreamUrl($.token),
video: {
fps: videoEncoderConfiguration?.rateControl?.frameRateLimit,
bitrate: computeBitrate(videoEncoderConfiguration?.rateControl?.bitrateLimit),
width: videoEncoderConfiguration?.resolution?.width,
height: videoEncoderConfiguration?.resolution?.height,
codec: videoEncoderConfiguration?.encoding?.toLowerCase(),
idrIntervalMillis: computeInterval(videoEncoderConfiguration?.rateControl?.frameRateLimit,
videoEncoderConfiguration?.$.GovLength),
},
audio: this.isAudioDisabled() ? null : {
bitrate: computeBitrate(audioEncoderConfiguration?.bitrate),
codec: convertAudioCodec(audioEncoderConfiguration?.encoding),
}
})
}
catch (e) {
this.console.error('error retrieving onvif profile', $.token, e);
}
}
if (!ret.length)
throw new Error('onvif camera had no profiles.');
resolve(ret);
}
catch (e) {
this.rtspMediaStreamOptions = undefined;
this.console.error('error retrieving onvif profiles', e);
resolve(undefined);
}
})
}
return this.rtspMediaStreamOptions;
}
async listenEvents() {
let motionTimeout: NodeJS.Timeout;
let binaryTimeout: NodeJS.Timeout;
const client = await this.createClient();
try {
await client.supportsEvents();
}
catch (e) {
}
await client.createSubscription();
try {
const eventTypes = await client.getEventTypes();
if (eventTypes?.length && this.storage.getItem('onvifDetector') !== 'true') {
this.storage.setItem('onvifDetector', 'true');
this.updateDevice();
}
}
catch (e) {
}
this.console.log('listening events');
const events = client.listenEvents();
events.on('event', (event, className) => {
if (event === OnvifEvent.MotionBuggy) {
this.motionDetected = true;
clearTimeout(motionTimeout);
motionTimeout = setTimeout(() => this.motionDetected = false, 30000);
return;
}
if (event === OnvifEvent.BinaryRingEvent) {
this.binaryState = true;
clearTimeout(binaryTimeout);
binaryTimeout = setTimeout(() => this.binaryState = false, 30000);
return;
}
if (event === OnvifEvent.MotionStart)
this.motionDetected = true;
else if (event === OnvifEvent.MotionStop)
this.motionDetected = false;
else if (event === OnvifEvent.AudioStart)
this.audioDetected = true;
else if (event === OnvifEvent.AudioStop)
this.audioDetected = false;
else if (event === OnvifEvent.BinaryStart)
this.binaryState = true;
else if (event === OnvifEvent.BinaryStop)
this.binaryState = false;
else if (event === OnvifEvent.Detection) {
const d: ObjectsDetected = {
timestamp: Date.now(),
detections: [
{
score: undefined,
className,
}
]
}
this.onDeviceEvent(ScryptedInterface.ObjectDetector, d);
}
});
const ret: Destroyable = {
destroy() {
client.unsubscribe();
},
on(eventName: string | symbol, listener: (...args: any[]) => void) {
return events.on(eventName, listener);
},
emit(eventName: string | symbol, ...args: any[]) {
return events.emit(eventName, ...args);
},
};
return ret;
}
createClient() {
return connectCameraAPI(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console, this.storage.getItem('onvifDoorbellEvent'));
}
async getClient() {
if (!this.client)
this.client = await this.createClient();
return this.client;
}
showRtspUrlOverride() {
return false;
}
showRtspPortOverride() {
return false;
}
showHttpPortOverride() {
return true;
}
showSnapshotUrlOverride() {
return false;
}
async getOtherSettings(): Promise<Setting[]> {
const isDoorbell = !!this.providedInterfaces?.includes(ScryptedInterface.BinarySensor);
const ret: Setting[] = [
...await super.getOtherSettings(),
{
title: 'Onvif Doorbell',
type: 'boolean',
description: 'Enable if this device is a doorbell',
key: 'onvifDoorbell',
value: isDoorbell.toString(),
},
{
title: 'Onvif Doorbell Event Name',
type: 'string',
description: 'Onvif event name to trigger the doorbell',
key: "onvifDoorbellEvent",
value: this.storage.getItem('onvifDoorbellEvent'),
placeholder: 'EventName'
},
];
if (!isDoorbell) {
ret.push(
{
title: 'Two Way Audio',
type: 'boolean',
key: 'onvifTwoWay',
value: (!!this.providedInterfaces?.includes(ScryptedInterface.Intercom)).toString(),
}
)
}
return ret;
}
updateDevice() {
const interfaces: string[] = [...this.provider.getInterfaces()];
if (this.storage.getItem('onvifDetector') === 'true')
interfaces.push(ScryptedInterface.ObjectDetector);
const doorbell = this.storage.getItem('onvifDoorbell') === 'true';
let type: ScryptedDeviceType;
if (doorbell) {
interfaces.push(ScryptedInterface.BinarySensor);
type = ScryptedDeviceType.Doorbell;
}
const twoWay = this.storage.getItem('onvifTwoWay') === 'true';
if (twoWay || doorbell)
interfaces.push(ScryptedInterface.Intercom);
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
}
async putSetting(key: string, value: string) {
this.client = undefined;
this.rtspMediaStreamOptions = undefined;
this.updateManagementUrl();
if (key !== 'onvifDoorbell' && key !== 'onvifTwoWay')
return super.putSetting(key, value);
this.storage.setItem(key, value);
this.updateDevice();
}
async startIntercom(media: MediaObject) {
const options = await this.getConstructedVideoStreamOptions();
const stream = options[0];
this.intercom.url = stream.url;
return this.intercom.startIntercom(media);
}
async stopIntercom() {
return this.intercom.stopIntercom();
}
}
class OnvifProvider extends RtspProvider implements DeviceDiscovery {
discoveredDevices = new Map<string, {
device: Device;
host: string;
port: string;
timeout: NodeJS.Timeout;
}>();
constructor(nativeId?: string) {
super(nativeId);
onvif.Discovery.on('device', (cam: any, rinfo: any, xml: any) => {
// Function will be called as soon as the NVT responses
// Parsing of Discovery responses taken from my ONVIF-Audit project, part of the 2018 ONVIF Open Source Challenge
// Filter out xml name spaces
xml = xml.replace(/xmlns([^=]*?)=(".*?")/g, '');
let parser = new xml2js.Parser({
attrkey: 'attr',
charkey: 'payload', // this ensures the payload is called .payload regardless of whether the XML Tags have Attributes or not
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix] // strip namespace eg tt:Data -> Data
});
parser.parseString(xml,
async (err: Error, result: any) => {
if (err) {
this.console.error('discovery error', err);
return;
}
const urn = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['EndpointReference'][0]['Address'][0].payload;
const xaddrs = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['XAddrs'][0].payload;
let name: string;
try {
let scopes = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['Scopes'][0].payload;
scopes = scopes.split(" ");
for (let i = 0; i < scopes.length; i++) {
if (scopes[i].includes('onvif://www.onvif.org/name')) { name = decodeURI(scopes[i].substring(27)); }
}
}
catch (e) {
}
this.console.log('Discovery Reply from ' + rinfo.address + ' (' + name + ') (' + xaddrs + ') (' + urn + ')');
if (deviceManager.getNativeIds().includes(urn) || this.discoveredDevices.has(urn))
return;
const device: Device = {
name,
nativeId: urn,
type: ScryptedDeviceType.Camera,
interfaces: this.getInterfaces(),
};
const onvifUrl = new URL(xaddrs);
clearTimeout(this.discoveredDevices.get(urn)?.timeout);
this.discoveredDevices.set(urn, {
device,
host: rinfo.address,
port: onvifUrl.port,
timeout: setTimeout(() => {
this.discoveredDevices.delete(urn);
}, 5 * 60 * 1000),
});
this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, await this.discoverDevices());
// const device = await this.getDevice(urn) as OnvifCamera;
// device.setIPAddress(rinfo.address);
// device.setHttpPortOverride(onvifUrl.port);
// this.log.a('Discovered ONVIF Camera. Complete setup by providing login credentials.');
}
);
})
}
getAdditionalInterfaces() {
return [
ScryptedInterface.Camera,
ScryptedInterface.AudioSensor,
ScryptedInterface.MotionSensor,
ScryptedInterface.VideoCameraConfiguration,
];
}
createCamera(nativeId: string): OnvifCamera {
return new OnvifCamera(nativeId, this);
}
async createDevice(settings: DeviceCreatorSettings, nativeId?: ScryptedNativeId): Promise<string> {
const httpAddress = `${settings.ip}:${settings.httpPort || 80}`;
let info: DeviceInformation;;
const username = settings.username?.toString();
const password = settings.password?.toString();
const skipValidate = settings.skipValidate === 'true';
if (!skipValidate) {
try {
const api = await connectCameraAPI(httpAddress, username, password, this.console, undefined);
const onvifInfo = await api.getDeviceInformation();
info = {
serialNumber: onvifInfo.serialNumber,
manufacturer: onvifInfo.manufacturer,
firmware: onvifInfo.firmwareVersion,
model: onvifInfo.model,
managementUrl: `http://${httpAddress}`,
}
settings.newCamera = info.model;
}
catch (e) {
this.console.error('Error adding ONVIF camera', e);
throw e;
}
}
settings.newCamera ||= 'ONVIF Camera';
nativeId = await super.createDevice(settings, nativeId);
const device = await this.getDevice(nativeId) as OnvifCamera;
device.info = info;
device.putSetting('username', username);
device.putSetting('password', password);
device.setIPAddress(settings.ip?.toString());
device.setHttpPortOverride(settings.httpPort?.toString());
return nativeId;
}
async getCreateDeviceSettings(): Promise<Setting[]> {
return [
{
key: 'username',
title: 'Username',
},
{
key: 'password',
title: 'Password',
type: 'password',
},
{
key: 'ip',
title: 'IP Address',
placeholder: '192.168.2.222',
},
{
key: 'httpPort',
title: 'HTTP Port',
description: 'Optional: Override the HTTP Port from the default value of 80',
placeholder: '80',
},
{
key: 'skipValidate',
title: 'Skip Validation',
description: 'Add the device without verifying the credentials and network settings.',
type: 'boolean',
}
]
}
async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
if (scan)
onvif.Discovery.probe();
return [...this.discoveredDevices.values()].map(d => ({
...d.device,
description: d.host,
settings: [
{
key: 'username',
title: 'Username',
},
{
key: 'password',
title: 'Password',
type: 'password',
},
]
}));
}
async adoptDevice(adopt: AdoptDevice): Promise<string> {
const entry = this.discoveredDevices.get(adopt.nativeId);
this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, await this.discoverDevices());
if (!entry)
throw new Error('device not found');
adopt.settings.ip = entry.host;
adopt.settings.httpPort = entry.port;
await this.createDevice(adopt.settings, adopt.nativeId);
this.discoveredDevices.delete(adopt.nativeId);
const device = await this.getDevice(adopt.nativeId) as OnvifCamera;
return device.id;
}
}
export default OnvifProvider;