UNPKG

@scrypted/amcrest

Version:

Amcrest Plugin for Scrypted

826 lines (719 loc) 30.8 kB
import { automaticallyConfigureSettings, checkPluginNeedsAutoConfigure } from "@scrypted/common/src/autoconfigure-codecs"; import { ffmpegLogInitialOutput } from '@scrypted/common/src/media-helpers'; import { readLength } from "@scrypted/common/src/read-stream"; import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, Lock, MediaObject, MediaStreamOptions, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, Reboot, RequestPictureOptions, RequestRecordingStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, VideoCameraConfiguration, VideoRecorder, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk"; import child_process, { ChildProcess } from 'child_process'; import { PassThrough, Readable, Stream } from "stream"; import { OnvifIntercom } from "../../onvif/src/onvif-intercom"; import { createRtspMediaStreamOptions, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; import { AmcrestCameraClient, AmcrestEvent, AmcrestEventData } from "./amcrest-api"; import { amcrestAutoConfigureSettings, autoconfigureSettings } from "./amcrest-configure"; const { mediaManager } = sdk; const AMCREST_DOORBELL_TYPE = 'Amcrest Doorbell'; const DAHUA_DOORBELL_TYPE = 'Dahua Doorbell'; const rtspChannelSetting: Setting = { subgroup: 'Advanced', key: 'rtspChannel', title: 'Channel Number Override', description: "The channel number to use for snapshots and video. E.g., 1, 2, etc.", placeholder: '1', }; class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, Camera, Intercom, Lock, VideoRecorder, Reboot, ObjectDetector, VideoTextOverlays { eventStream: Stream; cp: ChildProcess; client: AmcrestCameraClient; videoStreamOptions: Promise<UrlMediaStreamOptions[]>; onvifIntercom = new OnvifIntercom(this); hasSmartDetection: boolean; constructor(nativeId: string, provider: RtspProvider) { super(nativeId, provider); if (this.storage.getItem('amcrestDoorbell') === 'true') { this.storage.setItem('doorbellType', AMCREST_DOORBELL_TYPE); this.storage.removeItem('amcrestDoorbell'); } this.hasSmartDetection = this.storage.getItem('hasSmartDetection') === 'true'; this.updateDevice(); this.updateDeviceInfo(); } async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> { const client = this.getClient(); const response = await client.request({ method: "GET", url: `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=getConfig&name=VideoWidget`, responseType: "text", headers: { "Content-Type": "application/xml", }, }); const body: string = response.body; if (!body.startsWith("<")) { const encodeBlend = '.EncodeBlend'; const config: Record<string, VideoTextOverlay> = {}; for (const line of body.split(/\r?\n/).filter(l => l.includes(encodeBlend + '='))) { const trimmed = line.trim(); if (!trimmed) continue; const splitIndex = trimmed.indexOf("="); if (splitIndex === -1) continue; // remove encodeBlend let key = trimmed.substring(0, splitIndex); key = key.substring(0, key.length - encodeBlend.length); config[key] = { readonly: true, }; } const textValue = '.Text'; for (const line of body.split(/\r?\n/).filter(l => l.includes(textValue + '='))) { const trimmed = line.trim(); if (!trimmed) continue; const splitIndex = trimmed.indexOf("="); if (splitIndex === -1) continue; // remove encodeBlend let key = trimmed.substring(0, splitIndex); key = key.substring(0, key.length - textValue.length); const text = trimmed.substring(splitIndex + 1).trim(); const c = config[key]; if (!c) continue; delete c.readonly; c.text = text; } return config; } else { throw new Error('invalid response'); // const json = await xml2js.parseStringPromise(body); // return { json, xml: body }; } } async setVideoTextOverlay(id: string, value: VideoTextOverlay): Promise<void> { // trim the table. off id if (id.startsWith('table.')) id = id.substring('table.'.length); const client = this.getClient(); if (value.text) { const enableUrl = `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=setConfig&${id}.EncodeBlend=true&${id}.PreviewBlend=true`; await client.request({ method: "GET", url: enableUrl, responseType: "text", }); const textUrl = `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=setConfig&${id}.Text=${encodeURIComponent( value.text )}`; await client.request({ method: "GET", url: textUrl, responseType: "text", }); } else { const disableUrl = `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=setConfig&${id}.EncodeBlend=false&${id}.PreviewBlend=false`; await client.request({ method: "GET", url: disableUrl, responseType: "text", }); } } async reboot() { const client = this.getClient(); await client.reboot(); } getRecordingStreamCurrentTime(recordingStream: MediaObject): Promise<number> { throw new Error("Method not implemented."); } getRecordingStreamThumbnail(time: number): Promise<MediaObject> { throw new Error("Method not implemented."); } async getRecordingStream(options: RequestRecordingStreamOptions): Promise<MediaObject> { // ffplay 'rtsp://user:password@192.168.2.87/cam/playback?channel=1&starttime=2022_03_12_21_00_00' const startTime = new Date(options.startTime); const month = (startTime.getMonth() + 1).toString().padStart(2, '0'); const date = startTime.getDate().toString().padStart(2, '0'); const year = startTime.getFullYear(); const hours = startTime.getHours().toString().padStart(2, '0'); const minutes = startTime.getMinutes().toString().padStart(2, '0'); const seconds = startTime.getSeconds().toString().padStart(2, '0');; const url = `rtsp://${this.getRtspAddress()}/cam/playback?channel=1&starttime=${year}_${month}_${date}_${hours}_${minutes}_${seconds}`; const authedUrl = this.addRtspCredentials(url); return this.createMediaStreamUrl(authedUrl, undefined); } getRecordingStreamOptions(): Promise<ResponseMediaStreamOptions[]> { return this.getVideoStreamOptions(); } async updateDeviceInfo(): Promise<void> { const ip = this.storage.getItem('ip'); if (!ip) return; const managementUrl = `http://${ip}`; const deviceInfo: DeviceInformation = { ...this.info, ip, managementUrl, }; const deviceParameters = [ { action: "getVendor", replace: "vendor=", parameter: "manufacturer" }, { action: "getSerialNo", replace: "sn=", parameter: "serialNumber" }, { action: "getDeviceType", replace: "type=", parameter: "model" }, { action: "getSoftwareVersion", replace: "version=", parameter: "firmware" } ]; for (const element of deviceParameters) { try { const response = await this.getClient().request({ url: `http://${this.getHttpAddress()}/cgi-bin/magicBox.cgi?action=${element.action}`, responseType: 'text', }); const result = String(response.body).replace(element.replace, "").trim(); deviceInfo[element.parameter] = result; } catch (e) { this.console.error('Error getting device parameter', element.action, e); } } this.info = deviceInfo; } async setVideoStreamOptions(options: MediaStreamOptions) { const channel = parseInt(this.getRtspChannel()) || 1; const client = this.getClient(); return client.configureCodecs(channel, options); } getClient() { if (!this.client) this.client = new AmcrestCameraClient(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console); return this.client; } async listenEvents() { let motionTimeout: NodeJS.Timeout; const motionTimeoutDuration = 20000; const resetMotionTimeout = () => { clearTimeout(motionTimeout); motionTimeout = setTimeout(() => { this.motionDetected = false; }, motionTimeoutDuration); } const client = new AmcrestCameraClient(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console); const events = await client.listenEvents(); const doorbellType = this.storage.getItem('doorbellType'); const callerId = this.storage.getItem('callerID'); const multipleCallIds = this.storage.getItem('multipleCallIds') === 'true'; let pulseTimeout: NodeJS.Timeout; events.on('event', (event: AmcrestEvent, index: string, payload: string) => { const channelNumber = this.getRtspChannel(); if (channelNumber) { const idx = parseInt(index) + 1; if (idx.toString() !== channelNumber) return; } if (event === AmcrestEvent.MotionStart || event === AmcrestEvent.SmartMotionHuman || event === AmcrestEvent.SmartMotionVehicle || event === AmcrestEvent.CrossLineDetection || event === AmcrestEvent.CrossRegionDetection) { this.motionDetected = true; resetMotionTimeout(); } else if (event === AmcrestEvent.MotionInfo) { // this seems to be a motion pulse if (!this.motionDetected) this.motionDetected = true; resetMotionTimeout(); } else if (event === AmcrestEvent.MotionStop) { // use resetMotionTimeout } else if (event === AmcrestEvent.AudioStart) { this.audioDetected = true; } else if (event === AmcrestEvent.AudioStop) { this.audioDetected = false; } else if (event === AmcrestEvent.TalkInvite || event === AmcrestEvent.PhoneCallDetectStart || event === AmcrestEvent.AlarmIPCStart || event === AmcrestEvent.DahuaTalkInvite) { if (event === AmcrestEvent.DahuaTalkInvite && payload && multipleCallIds) { if (payload.includes(callerId)) { this.binaryState = true; } } else { this.binaryState = true; } } else if (event === AmcrestEvent.TalkHangup || event === AmcrestEvent.PhoneCallDetectStop || event === AmcrestEvent.AlarmIPCStop || event === AmcrestEvent.DahuaCallDeny || event === AmcrestEvent.DahuaTalkHangup) { this.binaryState = false; } else if (event === AmcrestEvent.TalkPulse && doorbellType === AMCREST_DOORBELL_TYPE) { if (payload.includes('Invite')) { this.binaryState = true; } else if (payload.includes('Hangup')) { this.binaryState = false; } } else if (event === AmcrestEvent.DahuaTalkPulse && doorbellType === DAHUA_DOORBELL_TYPE) { clearTimeout(pulseTimeout); pulseTimeout = setTimeout(() => this.binaryState = false, 3000); this.binaryState = true; } }); events.on('smart', (className: string, data: AmcrestEventData) => { if (!this.hasSmartDetection) { this.hasSmartDetection = true; this.storage.setItem('hasSmartDetection', 'true'); this.updateDevice(); } const detected: ObjectsDetected = { timestamp: Date.now(), detections: [ { score: 1, className, } ], }; this.onDeviceEvent(ScryptedInterface.ObjectDetector, detected); }); return events; } async getDetectionInput(detectionId: string, eventId?: any): Promise<MediaObject> { return; } async getObjectTypes(): Promise<ObjectDetectionTypes> { return { classes: [ 'person', 'face', 'car', ], } } async getOtherSettings(): Promise<Setting[]> { const ret = await super.getOtherSettings(); ret.push( { subgroup: 'Advanced', title: 'Doorbell Type', choices: [ 'Not a Doorbell', AMCREST_DOORBELL_TYPE, DAHUA_DOORBELL_TYPE, ], description: 'If this device is a doorbell, select the appropriate doorbell type.', value: this.storage.getItem('doorbellType') || 'Not a Doorbell', key: 'doorbellType', }, ); const doorbellType = this.storage.getItem('doorbellType'); const isDoorbell = doorbellType === AMCREST_DOORBELL_TYPE || doorbellType === DAHUA_DOORBELL_TYPE; let twoWayAudio = this.storage.getItem('twoWayAudio'); const choices = [ 'Amcrest', 'ONVIF', ]; if (!isDoorbell) choices.unshift('None'); twoWayAudio = choices.find(c => c === twoWayAudio); if (!twoWayAudio) twoWayAudio = isDoorbell ? 'Amcrest' : 'None'; if (doorbellType == DAHUA_DOORBELL_TYPE) { ret.push( { title: 'Enable Dahua Lock', key: 'enableDahuaLock', description: 'Some Dahua Doorbells have a built in lock/door access control.', type: 'boolean', value: (this.storage.getItem('enableDahuaLock') === 'true').toString(), } ); ret.push( { title: 'Multiple Call Buttons', key: 'multipleCallIds', description: 'Some Dahua Doorbells integrate multiple Call Buttons for apartment buildings.', type: 'boolean', value: (this.storage.getItem('multipleCallIds') === 'true').toString(), } ); } const multipleCallIds = this.storage.getItem('multipleCallIds'); if (multipleCallIds) { ret.push( { title: 'Caller ID', key: 'callerID', description: 'Caller ID', type: 'number', value: this.storage.getItem('callerID'), } ) } ret.push( { subgroup: 'Advanced', title: 'Two Way Audio', value: twoWayAudio, key: 'twoWayAudio', description: 'Amcrest cameras may support both Amcrest and ONVIF two way audio protocols. ONVIF generally performs better when supported.', choices, }, // sdcard write causes jitter. // { // title: 'Continuous Recording', // key: 'continuousRecording', // description: 'Continuously record onto the Camera SD Card.', // type: 'boolean', // value: (this.storage.getItem('continuousRecording') === 'true').toString(), // }, ); const ac = { ...automaticallyConfigureSettings, subgroup: 'Advanced', }; ac.type = 'button'; ret.push(ac); ret.push({ ...amcrestAutoConfigureSettings, subgroup: 'Advanced', }); return ret; } async takeSmartCameraPicture(options?: RequestPictureOptions): Promise<MediaObject> { return this.createMediaObject(await this.getClient().jpegSnapshot(options?.timeout), 'image/jpeg'); } async getUrlSettings() { const rtspChannel = { ...rtspChannelSetting, value: this.storage.getItem('rtspChannel'), }; return [ rtspChannel, ...await super.getUrlSettings(), ] } getRtspChannel() { return this.storage.getItem('rtspChannel'); } createRtspMediaStreamOptions(url: string, index: number) { const ret = createRtspMediaStreamOptions(url, index); ret.tool = 'scrypted'; return ret; } async getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> { const client = this.getClient(); if (this.videoStreamOptions) return this.videoStreamOptions; this.videoStreamOptions = (async () => { const cameraNumber = parseInt(this.getRtspChannel()) || 1; try { let vsos: UrlMediaStreamOptions[]; try { vsos = await client.getCodecs(cameraNumber); this.storage.setItem('vsosJSON', JSON.stringify(vsos)); } catch (e) { this.console.error('error retrieving stream configurations', e); vsos = JSON.parse(this.storage.getItem('vsosJSON')) as UrlMediaStreamOptions[]; } for (const [index, vso] of vsos.entries()) { vso.tool = 'scrypted'; vso.url = `rtsp://${this.getRtspAddress()}/cam/realmonitor?channel=${cameraNumber}&subtype=${index}`; } return vsos; } catch (e) { this.videoStreamOptions = undefined; const vsos = [...Array(2).keys()].map(subtype => { const ret = createRtspMediaStreamOptions(`rtsp://${this.getRtspAddress()}/cam/realmonitor?channel=${cameraNumber}&subtype=${subtype}`, subtype); ret.tool = 'scrypted'; return ret; }); return vsos; } })(); return this.videoStreamOptions; } updateDevice() { const doorbellType = this.storage.getItem('doorbellType'); const isDoorbell = doorbellType === AMCREST_DOORBELL_TYPE || doorbellType === DAHUA_DOORBELL_TYPE; // true is the legacy value before onvif was added. const twoWayAudio = this.storage.getItem('twoWayAudio') === 'true' || this.storage.getItem('twoWayAudio') === 'ONVIF' || this.storage.getItem('twoWayAudio') === 'Amcrest'; 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); } const enableDahuaLock = this.storage.getItem('enableDahuaLock') === 'true'; if (isDoorbell && doorbellType === DAHUA_DOORBELL_TYPE && enableDahuaLock) { interfaces.push(ScryptedInterface.Lock); } const continuousRecording = this.storage.getItem('continuousRecording') === 'true'; if (continuousRecording) interfaces.push(ScryptedInterface.VideoRecorder); if (this.hasSmartDetection) interfaces.push(ScryptedInterface.ObjectDetector); this.provider.updateDevice(this.nativeId, this.name, interfaces, type); } async putSetting(key: string, value: string) { if (key === automaticallyConfigureSettings.key) { const client = this.getClient(); autoconfigureSettings(client, parseInt(this.getRtspChannel()) || 1) .then(() => { this.log.a('Successfully configured settings.'); }) .catch(e => { this.log.a('There was an error automatically configuring settings. More information can be viewed in the console.'); this.console.error('error autoconfiguring', e); }); return; } if (key === 'continuousRecording') { if (value === 'true') { try { await this.getClient().enableContinousRecording(parseInt(this.getRtspChannel()) || 1); this.storage.setItem('continuousRecording', 'true'); } catch (e) { this.log.a('There was an error enabling continuous recording.'); this.console.error('There was an error enabling continuous recording.', e); } } else { this.storage.removeItem('continuousRecording'); } } this.client = undefined; this.videoStreamOptions = undefined; super.putSetting(key, value); this.updateDevice(); this.updateDeviceInfo(); } 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 doorbellType = this.storage.getItem('doorbellType'); // not sure if this all works, since i don't actually have a doorbell. // good luck! const channel = parseInt(this.getRtspChannel()) || 1; 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'); let contentType: string; if (doorbellType == DAHUA_DOORBELL_TYPE) { args.push( "-vn", '-acodec', 'pcm_alaw', '-ac', '1', '-ar', '8000', '-sample_fmt', 's16', '-f', 'alaw', 'pipe:3', ); contentType = 'Audio/G.711A'; } else { args.push( "-vn", '-acodec', 'aac', '-f', 'adts', 'pipe:3', ); contentType = 'Audio/AAC'; // args.push( // "-vn", // '-acodec', 'pcm_mulaw', // '-ac', '1', // '-ar', '8000', // '-sample_fmt', 's16', // '-f', 'mulaw', // 'pipe:3', // ); // contentType = 'Audio/G.711A'; } 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()}/cgi-bin/audio.cgi?action=postAudio&httptype=singlepart&channel=${channel}`; 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(); const abortController = new AbortController(); this.getClient().request({ url, method: 'POST', headers: { 'Content-Type': contentType, 'Content-Length': '9999999', }, signal: abortController.signal, responseType: 'readable', }, passthrough) .catch(() => { }) .finally(() => this.console.log('request finished')) try { while (true) { const data = await readLength(socket, 1024); passthrough.push(data); } } catch (e) { } finally { this.console.log('audio finished'); passthrough.destroy(); abortController.abort(); } this.stopIntercom(); })(); } async stopIntercom(): Promise<void> { if (this.storage.getItem('twoWayAudio') === 'ONVIF') { return this.onvifIntercom.stopIntercom(); } this.cp?.kill(); this.cp = undefined; } showRtspUrlOverride() { return false; } async lock(): Promise<void> { if (!this.client.lock()) { this.console.error("Could not lock"); } } async unlock(): Promise<void> { if (!this.client.unlock()) { this.console.error("Could not unlock"); } } } class AmcrestProvider extends RtspProvider { constructor(nativeId?: ScryptedNativeId) { super(nativeId); checkPluginNeedsAutoConfigure(this); } getAdditionalInterfaces() { return [ ScryptedInterface.Reboot, ScryptedInterface.VideoCameraConfiguration, ScryptedInterface.Camera, ScryptedInterface.AudioSensor, ScryptedInterface.MotionSensor, ScryptedInterface.VideoTextOverlays, ]; } getScryptedDeviceCreator(): string { return 'Amcrest Camera'; } async createDevice(settings: DeviceCreatorSettings, nativeId?: string): 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?.toString() === 'true'; let twoWayAudio: string; const api = new AmcrestCameraClient(httpAddress, username, password, this.console); if (settings.autoconfigure) { const cameraNumber = parseInt(settings.rtspChannel as string) || 1; await autoconfigureSettings(api, cameraNumber); } if (!skipValidate) { try { const deviceInfo = await api.getDeviceInfo(); settings.newCamera = deviceInfo.deviceType; info.model = deviceInfo.deviceType; info.serialNumber = deviceInfo.serialNumber; } catch (e) { this.console.error('Error adding Amcrest camera', e); throw e; } try { if (await api.checkTwoWayAudio()) { // onvif seems to work better than Amcrest, except for AD110. twoWayAudio = 'ONVIF'; } } catch (e) { this.console.warn('Error probing two way audio', e); } } settings.newCamera ||= 'Amcrest Camera'; nativeId = await super.createDevice(settings, nativeId); const device = await this.getDevice(nativeId) as AmcrestCamera; device.info = info; device.putSetting('username', username); device.putSetting('password', password); if (settings.rtspChannel) device.putSetting('rtspChannel', settings.rtspChannel as string); device.setHttpPortOverride(settings.httpPort?.toString()); device.setIPAddress(settings.ip?.toString()); if (twoWayAudio) device.putSetting('twoWayAudio', twoWayAudio); device.updateDeviceInfo(); 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', }, rtspChannelSetting, { subgroup: 'Advanced', key: 'httpPort', title: 'HTTP Port', description: 'Optional: Override the HTTP Port from the default value of 80.', placeholder: '80', }, automaticallyConfigureSettings, amcrestAutoConfigureSettings, { subgroup: 'Advanced', key: 'skipValidate', title: 'Skip Validation', description: 'Add the device without verifying the credentials and network settings.', type: 'boolean', } ] } createCamera(nativeId: string) { return new AmcrestCamera(nativeId, this); } } export default AmcrestProvider;