From 4bf028fc5d07b83ea9da53afe899a9013af6b127 Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Mon, 22 Aug 2022 23:37:53 -0700 Subject: [PATCH] hikvision: two way audio. onvif works. unclear if hikvision api does. --- plugins/hikvision/package-lock.json | 50 ++++- plugins/hikvision/package.json | 3 +- plugins/hikvision/src/hikvision-camera-api.ts | 13 +- plugins/hikvision/src/main.ts | 187 +++++++++++++++++- 4 files changed, 233 insertions(+), 20 deletions(-) diff --git a/plugins/hikvision/package-lock.json b/plugins/hikvision/package-lock.json index 1f8b5e02b..0e146636d 100644 --- a/plugins/hikvision/package-lock.json +++ b/plugins/hikvision/package-lock.json @@ -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", diff --git a/plugins/hikvision/package.json b/plugins/hikvision/package.json index 5cc7a50b0..f22de5159 100644 --- a/plugins/hikvision/package.json +++ b/plugins/hikvision/package.json @@ -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", diff --git a/plugins/hikvision/src/hikvision-camera-api.ts b/plugins/hikvision/src/hikvision-camera-api.ts index e370e1e04..6ee6f4a5b 100644 --- a/plugins/hikvision/src/hikvision-camera-api.ts +++ b/plugins/hikvision/src/hikvision-camera-api.ts @@ -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', diff --git a/plugins/hikvision/src/main.ts b/plugins/hikvision/src/main.ts index 4641dba3e..4eb1bdacf 100644 --- a/plugins/hikvision/src/main.ts +++ b/plugins/hikvision/src/main.ts @@ -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; 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 { + 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 { + 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 { + 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`, + }) } }