UNPKG

homebridge-dahua-alerts

Version:

Routes motion alerts for Dahua, Alhua and Lorex camera streams to `homebridge-camera-ffmpeg`

223 lines (183 loc) 10 kB
import Axios, {AxiosBasicCredentials, AxiosResponse, AxiosError, AxiosRequestConfig} from 'axios' import { Agent as HttpsAgent } from 'https' import { Agent as HttpAgent } from 'http' import { EventEmitter } from 'events' import crypto from 'crypto' import { Readable } from 'stream' class DahuaEvents { //cgi-bin/eventManager.cgi?action=attach&codes=[AlarmLocal,VideoMotion,VideoLoss,VideoBlind] private EVENTS_URI: string = '/cgi-bin/eventManager.cgi?action=attach&codes=[{codes}]' private HEADERS: any = {'Accept':'multipart/x-mixed-replace'} private RECONNECT_INTERNAL_MS: number = 10000 private AGENT_SETTINGS = { keepAlive: true, keepAliveMsecs: 1000, maxSockets: 1, maxFreeSockets: 1 //TODO: do we need a free socket? } private eventEmitter: EventEmitter public ALARM_EVENT_NAME: string = 'alarm' public DEBUG_EVENT_NAME: string = 'alarm_payload' public ERROR_EVENT_NAME: string = 'error' public DATA_EVENT_NAME: string = 'data' public RECONNECTING_EVENT_NAME: string = 'reconnecting' private host: string constructor(host: string, user: string, pass: string, useHttp: boolean, events: string[]) { this.host = host const auth: AxiosBasicCredentials = { username: user, password: pass } let keepAliveAgent if(useHttp) { keepAliveAgent = new HttpAgent({ keepAlive: this.AGENT_SETTINGS.keepAlive, keepAliveMsecs: this.AGENT_SETTINGS.keepAliveMsecs, maxSockets: this.AGENT_SETTINGS.maxSockets, maxFreeSockets: this.AGENT_SETTINGS.maxFreeSockets, }) } else { keepAliveAgent = new HttpsAgent({ keepAlive: this.AGENT_SETTINGS.keepAlive, keepAliveMsecs: this.AGENT_SETTINGS.keepAliveMsecs, maxSockets: this.AGENT_SETTINGS.maxSockets, maxFreeSockets: this.AGENT_SETTINGS.maxFreeSockets, rejectUnauthorized: false, minVersion: "TLSv1" }) } let useSSL = useHttp ? 'http': 'https' let eventsURI = this.EVENTS_URI.replace("{codes}", events.join(",")) const axiosRequestConfig: AxiosRequestConfig ={ url: `${useSSL}://${host}${eventsURI}`, httpsAgent: keepAliveAgent, httpAgent: keepAliveAgent, auth: auth, headers: this.HEADERS, method: 'GET', responseType: 'stream' } this.eventEmitter = new EventEmitter() this.connect(axiosRequestConfig, 0) } private connect = (axiosRequestConfig: AxiosRequestConfig, count: number) => { Axios.request(axiosRequestConfig).then((res: AxiosResponse) => { let stream: Readable = res.data this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Successfully connected and listening to host: ${this.host}`) this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Connection response received for host: ${this.host} ${JSON.stringify(res.headers)} ${JSON.stringify(res.statusText)} ${JSON.stringify(res.status)}`) stream.on(this.DATA_EVENT_NAME, (data: Buffer) => { this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Response recieved on host: ${this.host}: ${data.toString()}`) let event = this.parseEventData(data.toString()) this.eventEmitter.emit(this.ALARM_EVENT_NAME, {action: event.action, index: event.index, host: this.host} as DahuaAlarm) }) stream.on('close', () => { this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Socket connection closed for host: ${this.host}`) }) stream.on('error', (data: Buffer) => { this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Socket connection errored on host: ${this.host}, error received: ${data.toString()}`) this.reconnect(axiosRequestConfig, this.RECONNECT_INTERNAL_MS) }) stream.on('end', () => { this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Socket connection ended on host: ${this.host}`) this.reconnect(axiosRequestConfig, this.RECONNECT_INTERNAL_MS) }) }).catch((err: AxiosError) => { let error: DahuaError = { error: `Error received from host: ${this.host}`, errorDetails: "Error Details:" } // Request made and server responded with response if(err.response) { if(err.response.status === 401 && err.response.headers['www-authenticate']) { try { /* digest auth, build digest auth header * The two examples I've seen from these cameras are: * Digest realm="Login to ND021811019863",qop="auth",nonce="211955164",opaque="9a206a55e922ee7900769ec61ae49bf0c1f30242" * or: * Digest realm="Login to ND021811019863", qop="auth", nonce="211955164", opaque="9a206a55e922ee7900769ec61ae49bf0c1f30242" * The split() regex splits on the commas eliminating any potential white-spaces before and after the comma (s*) */ const authDetails = err.response.headers['www-authenticate'].split(/\s*,\s*/).map(v => v.split('=')) ++count const nonceCount = ('00000000' + count).slice(-8) const cnonce = crypto.randomBytes(24).toString('hex') const realm = authDetails[0][1].replace(/"/g, '') const nonce = authDetails[2][1].replace(/"/g, '') const md5 = str => crypto.createHash('md5').update(str).digest('hex') const HA1 = md5(`${axiosRequestConfig.auth?.username}:${realm}:${axiosRequestConfig.auth?.password}`) const HA2 = md5(`GET:${this.EVENTS_URI}`) const response = md5(`${HA1}:${nonce}:${nonceCount}:${cnonce}:auth:${HA2}`) this.HEADERS['authorization'] = `Digest username="${axiosRequestConfig.auth?.username}",realm="${realm}",` + `nonce="${nonce}",uri="${this.EVENTS_URI}",qop="auth",algorithm="MD5",` + `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"` axiosRequestConfig.headers = this.HEADERS this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `401 received and www-authenticate headers, sending digest auth. Count: ${count}`) this.connect(axiosRequestConfig, count) return } catch (e) { error.errorDetails = `${error.errorDetails} Error when building digest auth headers, please open an issue with this log: \n ${e}` } } else { error.errorDetails = `${error.errorDetails} Status Code: ${err.response.status} Response: ${err.response.data.statusMessage}` } // client never received a response, or request never left } else if(err.request) { error.errorDetails = `${error.errorDetails} Didn't get a response from the NVR - ${err.message}` } else { error.errorDetails = `${error.errorDetails} ${err.message}` } this.eventEmitter.emit(this.ERROR_EVENT_NAME, error) this.reconnect(axiosRequestConfig, this.RECONNECT_INTERNAL_MS) }) } private reconnect = (axiosRequestConfig: AxiosRequestConfig, reconnection_interval_ms: number) => { //reconnect after 30s this.eventEmitter.emit(this.RECONNECTING_EVENT_NAME, `Reconnecting to ${this.host} in ${reconnection_interval_ms/1000}s.`) setTimeout(() => { this.connect(axiosRequestConfig, 0) }, reconnection_interval_ms) } private parseEventData = (data: string) : {action: string, index: string} => { /** Sample data: myboundary Content-Type: text/plain Content-Length:36 Code=VideoMotion;action=Stop;index=0 ---or--- --myboundary Content-Type: text/plain Content-Length:77 Code=VideoMotion;action=Stop;index=5;data={ "SmartMotionEnable" : false } */ let action = "" let index = "" try { let eventSplitByLine = data.split('\n') eventSplitByLine.forEach(event => { if(event.includes(';')) { let alarm = event.split(';') action = alarm[1].substr(7) index = alarm[2].substr(6) } }) } catch (e) { this.eventEmitter.emit(this.DEBUG_EVENT_NAME, `Could not parse event data: ${data}`) } return { action: action, index: index } } public getEventEmitter = (): EventEmitter => { return this.eventEmitter } } type DahuaError = { error: string errorDetails: string } type DahuaAlarm = { action: string index: string host: string } export { DahuaEvents, DahuaAlarm, DahuaError }