Skip to content

Commit

Permalink
hikvision: implement smart events
Browse files Browse the repository at this point in the history
  • Loading branch information
koush committed Mar 26, 2024
1 parent b46a385 commit 6285016
Show file tree
Hide file tree
Showing 6 changed files with 331 additions and 67 deletions.
11 changes: 8 additions & 3 deletions common/src/read-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,17 @@ export async function readLine(readable: Readable) {
}

export async function readString(readable: Readable | Promise<Readable>) {
let data = '';
const buffer = await readBuffer(readable);
return buffer.toString();
}

export async function readBuffer(readable: Readable | Promise<Readable>) {
const buffers: Buffer[] = [];
readable = await readable;
readable.on('data', buffer => {
data += buffer.toString();
buffers.push(buffer);
});
readable.resume();
await once(readable, 'end')
return data;
return Buffer.concat(buffers);
}
69 changes: 62 additions & 7 deletions plugins/hikvision/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion plugins/hikvision/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.138",
"version": "0.0.139",
"description": "Hikvision Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
Expand Down Expand Up @@ -38,10 +38,12 @@
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/xml2js": "^0.4.11",
"content-disposition": "^0.5.4",
"lodash": "^4.17.21",
"xml2js": "^0.6.0"
},
"devDependencies": {
"@types/content-disposition": "^0.5.8",
"@types/node": "^18.15.11"
}
}
115 changes: 98 additions & 17 deletions plugins/hikvision/src/hikvision-camera-api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { AuthFetchCredentialState, HttpFetchOptions, authHttpFetch } from '@scrypted/common/src/http-auth-fetch';
import { readLine } from '@scrypted/common/src/read-stream';
import { parseHeaders, readBody, readMessage } from '@scrypted/common/src/rtsp-server';
import contentDisposition from 'content-disposition';
import { IncomingMessage } from 'http';
import { Readable } from 'stream';
import { EventEmitter, Readable } from 'stream';
import { Destroyable } from '../../rtsp/src/rtsp';
import { getDeviceInfo } from './probe';

export const detectionMap = {
human: 'person',
}

export function getChannel(channel: string) {
return channel || '101';
}
Expand All @@ -15,6 +23,8 @@ export enum HikvisionCameraEvent {
// <eventType>linedetection</eventType>
// <eventState>inactive</eventState>
LineDetection = "<eventType>linedetection</eventType>",
RegionEntrance = "<eventType>regionEntrance</eventType>",
RegionExit = "<eventType>regionExit</eventType>",
// <eventType>fielddetection</eventType>
// <eventState>active</eventState>
// <eventType>fielddetection</eventType>
Expand All @@ -31,7 +41,7 @@ export interface HikvisionCameraStreamSetup {
export class HikvisionCameraAPI {
credential: AuthFetchCredentialState;
deviceModel: Promise<string>;
listenerPromise: Promise<IncomingMessage>;
listenerPromise: Promise<Destroyable>;

constructor(public ip: string, username: string, password: string, public console: Console) {
this.credential = {
Expand Down Expand Up @@ -129,35 +139,106 @@ export class HikvisionCameraAPI {
return response.body;
}

async listenEvents() {
async listenEvents(): Promise<Destroyable> {
const events = new EventEmitter();
(events as any).destroy = () => { };
// support multiple cameras listening to a single single stream
if (!this.listenerPromise) {
const url = `http://${this.ip}/ISAPI/Event/notification/alertStream`;


let lastSmartDetection: string;

this.listenerPromise = this.request({
url,
responseType: 'readable',
}).then(response => {
const stream = response.body;
const stream: IncomingMessage = response.body;
(events as any).destroy = () => {
stream.destroy();
events.removeAllListeners();
};
stream.on('close', () => {
this.listenerPromise = undefined;
events.emit('close');
});
stream.on('end', () => {
this.listenerPromise = undefined;
events.emit('end');
});
stream.on('error', e => {
events.emit('error', e);
});
stream.socket.setKeepAlive(true);

stream.on('data', (buffer: Buffer) => {
const data = buffer.toString();
for (const event of Object.values(HikvisionCameraEvent)) {
if (data.indexOf(event) !== -1) {
const cameraNumber = data.match(/<channelID>(.*?)</)?.[1] || data.match(/<dynChannelID>(.*?)</)?.[1];
const inactive = data.indexOf('<eventState>inactive</eventState>') !== -1;
stream.emit('event', event, cameraNumber, inactive, data);
const ct = stream.headers['content-type'];
// make content type parsable as content disposition filename
const cd = contentDisposition.parse(ct.replace('/', ''));
let { boundary } = cd.parameters;
boundary = `--${boundary}`;
const boundaryEnd = `${boundary}--`;


(async () => {
while (true) {
let ignore = await readLine(stream);
ignore = ignore.trim();
if (!ignore)
continue;
if (ignore === boundaryEnd)
continue;
if (ignore !== boundary) {
this.console.error('expected boundary but found', ignore);
throw new Error('expected boundary');
}

const message = await readMessage(stream);
events.emit('data', message);
message.unshift('');
const headers = parseHeaders(message);
const body = await readBody(stream, headers);

try {
if (!headers['content-type'].includes('application/xml') && lastSmartDetection) {
const cd = contentDisposition.parse(headers['content-disposition'] || 'empty');
if (!headers['content-type']?.startsWith('image/jpeg')) {
continue;
}
events.emit('smart', lastSmartDetection, body);
lastSmartDetection = undefined;
continue;
}

}
finally {
// is it possible that smart detections are sent without images?
// if so, flush this detection.
if (lastSmartDetection) {
events.emit('smart', lastSmartDetection);
}
}

const data = body.toString();
events.emit('data', data);
for (const event of Object.values(HikvisionCameraEvent)) {
if (data.indexOf(event) !== -1) {
const cameraNumber = data.match(/<channelID>(.*?)</)?.[1] || data.match(/<dynChannelID>(.*?)</)?.[1];
const inactive = data.indexOf('<eventState>inactive</eventState>') !== -1;
events.emit('event', event, cameraNumber, inactive, data);
if (event === HikvisionCameraEvent.LineDetection
|| event === HikvisionCameraEvent.RegionEntrance
|| event === HikvisionCameraEvent.RegionExit
|| event === HikvisionCameraEvent.FieldDetection) {
lastSmartDetection = data;
}
}
}
}
});
return stream;
})()
.catch(() => stream.destroy());
return events as any as Destroyable;
});
this.listenerPromise.catch(() => this.listenerPromise = undefined);
this.listenerPromise.then(stream => {
stream.on('close', () => this.listenerPromise = undefined);
stream.on('end', () => this.listenerPromise = undefined);
});
}

return this.listenerPromise;
Expand Down

0 comments on commit 6285016

Please sign in to comment.