hikvision: two way audio. onvif works. unclear if hikvision api does.

This commit is contained in:
Koushik Dutta
2022-08-22 23:37:53 -07:00
parent 32f1ed14dd
commit 4bf028fc5d
4 changed files with 233 additions and 20 deletions

View File

@@ -1,16 +1,16 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.97",
"version": "0.0.98",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/hikvision",
"version": "0.0.97",
"hasInstallScript": true,
"version": "0.0.98",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/highland": "^2.12.14",
"@types/lodash": "^4.14.172",
@@ -25,9 +25,24 @@
"xml2js": "^0.4.23"
}
},
"../../common": {
"name": "@scrypted/common",
"version": "1.0.1",
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
},
"devDependencies": {
"@types/node": "^16.9.0"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.0.199",
"version": "0.0.206",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.16.7",
@@ -81,6 +96,10 @@
"follow-redirects": "^1.14.0"
}
},
"node_modules/@scrypted/common": {
"resolved": "../../common",
"link": true
},
"node_modules/@scrypted/sdk": {
"resolved": "../../sdk",
"link": true
@@ -187,9 +206,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"funding": [
{
"type": "individual",
@@ -450,6 +469,17 @@
}
}
},
"@scrypted/common": {
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"@types/node": "^16.9.0",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
}
},
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
@@ -562,9 +592,9 @@
}
},
"follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
},
"get-symbol-from-current-process-h": {
"version": "1.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.97",
"version": "0.0.98",
"description": "HikVision Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
@@ -35,6 +35,7 @@
]
},
"dependencies": {
"@scrypted/common": "file:../../common",
"@koush/axios-digest-auth": "^0.8.5",
"@scrypted/sdk": "file:../../sdk",
"@types/highland": "^2.12.14",

View File

@@ -1,9 +1,12 @@
import { Readable } from 'stream';
import AxiosDigestAuth from '@koush/axios-digest-auth';
import { EventEmitter } from "stream";
import { IncomingMessage } from 'http';
import https from 'https';
function getChannel(channel: string) {
export const hikvisionHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
export function getChannel(channel: string) {
return channel || '101';
}
@@ -45,6 +48,7 @@ export class HikVisionCameraAPI {
this.deviceModel = new Promise(async (resolve, reject) => {
try {
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/ISAPI/System/deviceInfo`,
@@ -78,6 +82,7 @@ export class HikVisionCameraAPI {
}
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/ISAPI/Streaming/channels/${getChannel(channel)}/capabilities`,
@@ -98,6 +103,7 @@ export class HikVisionCameraAPI {
const url = `http://${this.ip}/ISAPI/Streaming/channels/${getChannel(channel)}/picture?snapShotImageType=JPEG`
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'arraybuffer',
url: url,
@@ -114,6 +120,7 @@ export class HikVisionCameraAPI {
// this.console.log('listener url', url);
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
url,
responseType: 'stream',

View File

@@ -1,14 +1,20 @@
import sdk, { MediaObject, Camera, ScryptedInterface, Setting } from "@scrypted/sdk";
import { EventEmitter } from "stream";
import { HikVisionCameraAPI } from "./hikvision-camera-api";
import { Destroyable, UrlMediaStreamOptions, RtspProvider, RtspSmartCamera } from "../../rtsp/src/rtsp";
import { ffmpegLogInitialOutput } from '@scrypted/common/src/media-helpers';
import { readLength } from '@scrypted/common/src/read-stream';
import sdk, { Camera, FFmpegInput, Intercom, MediaObject, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting } from "@scrypted/sdk";
import child_process, { ChildProcess } from 'child_process';
import { PassThrough, Readable } from "stream";
import { sleep } from "../../../common/src/sleep";
import { HikVisionCameraEvent } from "./hikvision-camera-api";
import { OnvifIntercom } from "../../onvif/src/onvif-intercom";
import { RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
import { getChannel, HikVisionCameraAPI, HikVisionCameraEvent, hikvisionHttpsAgent } from "./hikvision-camera-api";
const { mediaManager } = sdk;
class HikVisionCamera extends RtspSmartCamera implements Camera {
class HikVisionCamera extends RtspSmartCamera implements Camera, Intercom {
channelIds: Promise<string[]>;
client: HikVisionCameraAPI;
onvifIntercom = new OnvifIntercom(this);
cp: ChildProcess;
// bad hack, but whatever.
codecCheck = (async () => {
@@ -29,6 +35,24 @@ class HikVisionCamera extends RtspSmartCamera implements Camera {
}
})();
constructor(nativeId: string, provider: RtspProvider) {
super(nativeId, provider);
this.updateManagementUrl();
}
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;
}
}
async listenEvents() {
let motionTimeout: NodeJS.Timeout;
const api = (this.provider as HikVisionProvider).createSharedClient(this.getHttpAddress(), this.getUsername(), this.getPassword());
@@ -169,6 +193,7 @@ class HikVisionCamera extends RtspSmartCamera implements Camera {
resolve([camNumber + '01', camNumber + '02']);
} else try {
const response = await client.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
url: `http://${this.getHttpAddress()}/ISAPI/Streaming/channels`,
responseType: 'text',
});
@@ -213,6 +238,156 @@ class HikVisionCamera extends RtspSmartCamera implements Camera {
this.client = undefined;
this.channelIds = undefined;
super.putSetting(key, value);
const doorbellType = this.storage.getItem('doorbellType');
const isDoorbell = doorbellType === 'true';
const twoWayAudio = this.storage.getItem('twoWayAudio') === 'true'
|| this.storage.getItem('twoWayAudio') === 'ONVIF'
|| this.storage.getItem('twoWayAudio') === 'Hikvision';
const interfaces = this.provider.getInterfaces();
let type: ScryptedDeviceType = undefined;
if (isDoorbell) {
type = ScryptedDeviceType.Doorbell;
interfaces.push(ScryptedInterface.BinarySensor)
}
if (isDoorbell || twoWayAudio) {
interfaces.push(ScryptedInterface.Intercom);
}
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
this.updateManagementUrl();
}
async getOtherSettings(): Promise<Setting[]> {
const ret = await super.getOtherSettings();
const doorbellType = this.storage.getItem('doorbellType');
const isDoorbell = doorbellType === 'true';
let twoWayAudio = this.storage.getItem('twoWayAudio');
const choices = [
'Hikvision',
'ONVIF',
];
if (!isDoorbell)
choices.unshift('None');
twoWayAudio = choices.find(c => c === twoWayAudio);
if (!twoWayAudio)
twoWayAudio = isDoorbell ? 'Hikvision' : 'None';
ret.push(
{
title: 'Doorbell',
type: 'boolean',
description: 'This device is a Hikvision doorbell.',
value: isDoorbell,
key: 'doorbellType',
},
{
title: 'Two Way Audio',
value: twoWayAudio,
key: 'twoWayAudio',
description: 'Hikvision cameras may support both Hikvision and ONVIF two way audio protocols. ONVIF generally performs better when supported.',
choices,
},
);
return ret;
}
async startIntercom(media: MediaObject): Promise<void> {
if (this.storage.getItem('twoWayAudio') === 'ONVIF') {
const options = await this.getConstructedVideoStreamOptions();
const stream = options[0];
const url = new URL(stream.url);
// amcrest onvif requires this proto query parameter, or onvif two way
// will not activate.
url.searchParams.set('proto', 'Onvif');
this.onvifIntercom.url = url.toString();
return this.onvifIntercom.startIntercom(media);
}
const buffer = await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput);
const ffmpegInput = JSON.parse(buffer.toString()) as FFmpegInput;
const args = ffmpegInput.inputArguments.slice();
args.unshift('-hide_banner');
args.push(
"-vn",
'-ar', '8000',
'-ac', '1',
'-acodec', 'pcm_mulaw',
'-f', 'mulaw',
'pipe:3',
);
this.console.log('ffmpeg intercom', args);
const ffmpeg = await mediaManager.getFFmpegPath();
this.cp = child_process.spawn(ffmpeg, args, {
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
});
this.cp.on('exit', () => this.cp = undefined);
ffmpegLogInitialOutput(this.console, this.cp);
const socket = this.cp.stdio[3] as Readable;
(async () => {
const url = `http://${this.getHttpAddress()}/ISAPI/System/TwoWayAudio/channels/${this.getRtspChannel() || '1'}/audioData`;
this.console.log('posting audio data to', url);
// seems the dahua doorbells preferred 1024 chunks. should investigate adts
// parsing and sending multipart chunks instead.
const passthrough = new PassThrough();
this.getClient().digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: 'PUT',
url,
headers: {
'Content-Type': 'Audio/G.711Mu',
// 'Connection': 'close',
// 'Content-Length': '9999999'
},
data: passthrough,
});
try {
while (true) {
const data = await readLength(socket, 1024);
passthrough.push(data);
}
}
catch (e) {
}
finally {
this.console.log('audio finished');
passthrough.end();
}
this.stopIntercom();
})();
}
async stopIntercom(): Promise<void> {
if (this.storage.getItem('twoWayAudio') === 'ONVIF') {
return this.onvifIntercom.stopIntercom();
}
const client = this.getClient();
await client.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: 'PUT',
url: `http://${this.getHttpAddress()}/ISAPI/System/TwoWayAudio/channels/${this.getRtspChannel() || '1'}/close`,
})
}
}