UNPKG

@scrypted/reolink

Version:

Reolink Plugin for Scrypted

1,189 lines (1,052 loc) 41.4 kB
import { sleep } from '@scrypted/common/src/sleep'; import sdk, { Sleep, Brightness, Camera, Device, DeviceCreatorSettings, DeviceInformation, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestPictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk"; import { StorageSettings } from '@scrypted/sdk/storage-settings'; import { EventEmitter } from "stream"; import { createRtspMediaStreamOptions, Destroyable, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; import { OnvifCameraAPI, OnvifEvent, connectCameraAPI } from './onvif-api'; import { listenEvents } from './onvif-events'; import { OnvifIntercom } from './onvif-intercom'; import { DevInfo } from './probe'; import { AIState, Enc, ReolinkCameraClient } from './reolink-api'; class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff { sirenTimeout: NodeJS.Timeout; constructor(public camera: ReolinkCamera, nativeId: string) { super(nativeId); } async turnOff() { this.on = false; await this.setSiren(false); } async turnOn() { this.on = true; await this.setSiren(true); } private async setSiren(on: boolean) { const api = this.camera.getClient(); // doorbell doesn't seem to support alarm_mode = 'manul' if (this.camera.storageSettings.values.doorbell) { if (!on) { clearInterval(this.sirenTimeout); await api.setSiren(false); return; } // siren lasts around 4 seconds. this.sirenTimeout = setTimeout(async () => { await this.turnOff(); }, 4000); await api.setSiren(true, 1); return; } await api.setSiren(on); } } class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brightness { constructor(public camera: ReolinkCamera, nativeId: string) { super(nativeId); } async setBrightness(brightness: number): Promise<void> { this.brightness = brightness; await this.setFloodlight(undefined, brightness); } async turnOff() { this.on = false; await this.setFloodlight(false); } async turnOn() { this.on = true; await this.setFloodlight(true); } private async setFloodlight(on?: boolean, brightness?: number) { const api = this.camera.getClientWithToken(); await api.setWhiteLedState(on, brightness); } } class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff { constructor(public camera: ReolinkCamera, nativeId: string) { super(nativeId); } async turnOff() { this.on = false; await this.setPir(false); } async turnOn() { this.on = true; await this.setPir(true); } private async setPir(on: boolean) { const api = this.camera.getClientWithToken(); await api.setPirState(on); } } class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, Reboot, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays { client: ReolinkCameraClient; clientWithToken: ReolinkCameraClient; onvifClient: OnvifCameraAPI; onvifIntercom = new OnvifIntercom(this); videoStreamOptions: Promise<UrlMediaStreamOptions[]>; motionTimeout: NodeJS.Timeout; siren: ReolinkCameraSiren; floodlight: ReolinkCameraFloodlight; pirSensor: ReolinkCameraPirSensor; batteryTimeout: NodeJS.Timeout; storageSettings = new StorageSettings(this, { doorbell: { title: 'Doorbell', description: 'This camera is a Reolink Doorbell.', type: 'boolean', }, rtmpPort: { subgroup: 'Advanced', title: 'RTMP Port Override', placeholder: '1935', type: 'number', }, motionTimeout: { subgroup: 'Advanced', title: 'Motion Timeout', defaultValue: 20, type: 'number', }, hasObjectDetector: { json: true, hide: true, }, ptz: { subgroup: 'Advanced', title: 'PTZ Capabilities', choices: [ 'Pan', 'Tilt', 'Zoom', ], multiple: true, onPut: async () => { await this.updateDevice(); this.updatePtzCaps(); }, }, presets: { subgroup: 'Advanced', title: 'Presets', description: 'PTZ Presets in the format "id=name". Where id is the PTZ Preset identifier and name is a friendly name.', multiple: true, defaultValue: [], combobox: true, onPut: async (ov, presets: string[]) => { const caps = { ...this.ptzCapabilities, presets: {}, }; for (const preset of presets) { const [key, name] = preset.split('='); caps.presets[key] = name; } this.ptzCapabilities = caps; }, mapGet: () => { const presets = this.ptzCapabilities?.presets || {}; return Object.entries(presets).map(([key, name]) => key + '=' + name); }, }, cachedPresets: { multiple: true, hide: true, json: true, defaultValue: [], }, deviceInfo: { json: true, hide: true }, abilities: { json: true, hide: true }, useOnvifDetections: { subgroup: 'Advanced', title: 'Use ONVIF for Object Detection', choices: [ 'Default', 'Enabled', 'Disabled', ], defaultValue: 'Default', }, useOnvifTwoWayAudio: { subgroup: 'Advanced', title: 'Use ONVIF for Two-Way Audio', type: 'boolean', }, }); constructor(nativeId: string, provider: RtspProvider) { super(nativeId, provider); this.storageSettings.settings.useOnvifTwoWayAudio.onGet = async () => { return { hide: !!this.storageSettings.values.doorbell, } }; this.storageSettings.settings.ptz.onGet = async () => { return { hide: !!this.storageSettings.values.doorbell, } }; this.storageSettings.settings.presets.onGet = async () => { const choices = this.storageSettings.values.cachedPresets.map((preset) => preset.id + '=' + preset.name); return { choices, }; }; this.updateDeviceInfo(); (async () => { this.updatePtzCaps(); try { await this.getPresets(); } catch (e) { this.console.log('Fail fetching presets', e); } const api = this.getClient(); const deviceInfo = await api.getDeviceInfo(); this.console.log('deviceInfo', JSON.stringify(deviceInfo)); this.storageSettings.values.deviceInfo = deviceInfo; await this.updateAbilities(); await this.updateDevice(); await this.reportDevices(); this.startDevicesStatesPolling(); })() .catch(e => { this.console.log('device refresh failed', e); }); } async pollDeviceStates() { try { const api = this.getClient(); try { if (this.hasFloodlight() && this.floodlight) { const { enabled } = await api.getWhiteLedState(); if (enabled !== this.floodlight.on) { this.floodlight.on = enabled; } } } catch { } // try { // if (this.hasSiren() && this.siren) { // const { enabled } = await api.getSiren(); // if (enabled !== this.siren.on) { // this.siren.on = enabled; // } // } // } catch { } try { if (this.hasPirSensor() && this.pirSensor) { const { enabled } = await api.getPirState(); if (enabled !== this.pirSensor.on) { this.pirSensor.on = enabled; } } } catch { } } catch (e) { this.console.error('Error in pollDeviceStates', e); } } async startDevicesStatesPolling() { if ( !this.hasBattery() && (this.hasFloodlight() || this.hasSiren() || this.hasPirSensor()) ) { while (true) { await this.pollDeviceStates(); await sleep(1000 * 5); } } } async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> { const client = this.getClient(); const osd = await client.getOsd(); return { osdChannel: { text: osd.value.Osd.osdChannel.enable ? osd.value.Osd.osdChannel.name : undefined, }, osdTime: { text: !!osd.value.Osd.osdTime.enable, readonly: true, } } } async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise<void> { const client = this.getClient(); const osd = await client.getOsd(); if (id === 'osdChannel') { const osdValue = osd.value.Osd.osdChannel; osdValue.enable = value.text ? 1 : 0; // name must always be valid. osdValue.name = typeof value.text === 'string' && value.text ? value.text : osdValue.name || 'Camera'; } else if (id === 'osdTime') { const osdValue = osd.value.Osd.osdTime; osdValue.enable = value.text ? 1 : 0; } else { throw new Error('unknown overlay: ' + id); } await client.setOsd(osd); } updatePtzCaps() { const { ptz } = this.storageSettings.values; this.ptzCapabilities = { ...this.ptzCapabilities, pan: ptz?.includes('Pan'), tilt: ptz?.includes('Tilt'), zoom: ptz?.includes('Zoom'), } } async getPresets() { const client = this.getClient(); const ptzPresets = await client.getPtzPresets(); this.console.log(`Presets: ${JSON.stringify(ptzPresets)}`) this.storageSettings.values.cachedPresets = ptzPresets; } async updateAbilities() { const api = this.getClient(); const apiWithToken = this.getClientWithToken(); let abilities; try { abilities = await api.getAbility(); } catch (e) { abilities = await apiWithToken.getAbility(); } this.storageSettings.values.abilities = abilities; this.console.log('getAbility', JSON.stringify(abilities)); } supportsOnvifDetections() { const onvif: string[] = [ // wifi 'CX410W', 'Reolink Video Doorbell WiFi', // poe 'CX410', 'CX810', 'Reolink Video Doorbell PoE', ]; return onvif.includes(this.storageSettings.values.deviceInfo?.model); } async getDetectionInput(detectionId: string, eventId?: any): Promise<MediaObject> { return; } async ptzCommand(command: PanTiltZoomCommand): Promise<void> { const client = this.getClient(); client.ptz(command); } async getObjectTypes(): Promise<ObjectDetectionTypes> { try { const ai: AIState = this.storageSettings.values.hasObjectDetector?.value; const classes: string[] = []; for (const key of Object.keys(ai)) { if (key === 'channel') continue; const { alarm_state, support } = ai[key]; if (support) classes.push(key); } return { classes, }; } catch (e) { return { classes: [], }; } } async startIntercom(media: MediaObject): Promise<void> { if (!this.onvifIntercom.url) { const client = await this.getOnvifClient(); const streamUrl = await client.getStreamUrl(); this.onvifIntercom.url = streamUrl; } return this.onvifIntercom.startIntercom(media); } stopIntercom(): Promise<void> { return this.onvifIntercom.stopIntercom(); } hasSiren() { const channel = this.getRtspChannel(); const mainAbility = this.storageSettings.values.abilities?.value?.Ability?.supportAudioAlarm const channelAbility = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[channel]?.supportAudioAlarm return (mainAbility && mainAbility?.ver !== 0) || (channelAbility && channelAbility?.ver !== 0); } hasFloodlight() { const channel = this.getRtspChannel(); const channelData = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[channel]; if (channelData) { const floodLightConfigVer = channelData.floodLight?.ver ?? 0; const supportFLswitchConfigVer = channelData.supportFLswitch?.ver ?? 0; const supportFLBrightnessConfigVer = channelData.supportFLBrightness?.ver ?? 0; return floodLightConfigVer > 0 || supportFLswitchConfigVer > 0 || supportFLBrightnessConfigVer > 0; } return false; } hasBattery() { const batteryConfigVer = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[this.getRtspChannel()]?.battery?.ver ?? 0; return batteryConfigVer > 0; } hasPirEvents() { const pirEvents = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[this.getRtspChannel()]?.mdWithPir?.ver ?? 0; return pirEvents > 0; } hasPirSensor() { const batteryConfigVer = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[this.getRtspChannel()]?.mdWithPir?.ver ?? 0; return batteryConfigVer > 0; } async updateDevice() { const interfaces = this.provider.getInterfaces(); let type = ScryptedDeviceType.Camera; let name = 'Reolink Camera'; if (this.storageSettings.values.doorbell) { interfaces.push( ScryptedInterface.BinarySensor, ); type = ScryptedDeviceType.Doorbell; name = 'Reolink Doorbell'; } if (this.storageSettings.values.doorbell || this.storageSettings.values.useOnvifTwoWayAudio) { interfaces.push( ScryptedInterface.Intercom ); } if (this.storageSettings.values.ptz?.length) { interfaces.push(ScryptedInterface.PanTiltZoom); } if (this.storageSettings.values.hasObjectDetector) { interfaces.push(ScryptedInterface.ObjectDetector); } if (this.hasSiren() || this.hasFloodlight() || this.hasPirSensor()) interfaces.push(ScryptedInterface.DeviceProvider); if (this.hasBattery()) { interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep); this.startBatteryCheckInterval(); } await this.provider.updateDevice(this.nativeId, this.name ?? name, interfaces, type); } startBatteryCheckInterval() { if (this.batteryTimeout) { clearInterval(this.batteryTimeout); } this.batteryTimeout = setInterval(async () => { const api = this.getClientWithToken(); try { const { batteryPercent, sleeping } = await api.getBatteryInfo(); this.batteryLevel = batteryPercent; if (sleeping !== this.sleeping) { this.sleeping = sleeping; if (!sleeping) { await this.pollDeviceStates(); } } if (batteryPercent !== this.batteryLevel) { this.batteryLevel = batteryPercent; } } catch (e) { this.console.log('Error in getting battery info', e); } }, 1000 * 10); } async reboot() { const client = this.getClient(); await client.reboot(); } updateDeviceInfo() { const ip = this.storage.getItem('ip'); if (!ip) return; const info = this.info || {}; info.ip = ip; info.serialNumber = this.storageSettings.values.deviceInfo?.serial; info.firmware = this.storageSettings.values.deviceInfo?.firmVer; info.version = this.storageSettings.values.deviceInfo?.hardVer; info.model = this.storageSettings.values.deviceInfo?.model; info.manufacturer = 'Reolink'; info.managementUrl = `http://${ip}`; this.info = info; } getClient() { if (!this.client) this.client = new ReolinkCameraClient(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.getRtspChannel(), this.console); return this.client; } getClientWithToken() { if (!this.clientWithToken) this.clientWithToken = new ReolinkCameraClient(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.getRtspChannel(), this.console, true); return this.clientWithToken; } async getOnvifClient() { if (!this.onvifClient) this.onvifClient = await this.createOnvifClient(); return this.onvifClient; } createOnvifClient() { return connectCameraAPI(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console, this.storageSettings.values.doorbell ? this.storage.getItem('onvifDoorbellEvent') : undefined); } async listenEvents() { let killed = false; const client = this.getClient(); // reolink ai might not trigger motion if objects are detected, weird. const startAI = async (ret: Destroyable, triggerMotion: () => void) => { let hasSucceeded = false; let hasSet = false; while (!killed) { try { const ai = this.hasPirEvents() ? await client.getEvents() : await client.getAiState(); ret.emit('data', JSON.stringify(ai.data)); const classes: string[] = []; for (const key of Object.keys(ai.value)) { if (key === 'channel') continue; const { support } = ai.value[key]; if (support) classes.push(key); } if (!classes.length) return; if (!hasSet) { hasSet = true; this.storageSettings.values.hasObjectDetector = ai; } hasSucceeded = true; const od: ObjectsDetected = { timestamp: Date.now(), detections: [], }; for (const c of classes) { const { alarm_state } = ai.value[c]; if (alarm_state) { od.detections.push({ className: c, score: 1, }); } } if (od.detections.length) { triggerMotion(); sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od); } } catch (e) { if (!hasSucceeded) return; ret.emit('error', e); } await sleep(1000); } } const useOnvifDetections: boolean = (this.storageSettings.values.useOnvifDetections === 'Default' && (this.supportsOnvifDetections() || this.storageSettings.values.doorbell)) || this.storageSettings.values.useOnvifDetections === 'Enabled'; if (useOnvifDetections) { const ret = await listenEvents(this, await this.createOnvifClient(), this.storageSettings.values.motionTimeout * 1000); ret.on('onvifEvent', (eventTopic: string, dataValue: any) => { let className: string; if (eventTopic.includes('PeopleDetect')) { className = 'people'; } else if (eventTopic.includes('FaceDetect')) { className = 'face'; } else if (eventTopic.includes('VehicleDetect')) { className = 'vehicle'; } else if (eventTopic.includes('DogCatDetect')) { className = 'dog_cat'; } else if (eventTopic.includes('Package')) { className = 'package'; } if (className && dataValue) { ret.emit('event', OnvifEvent.MotionStart); const od: ObjectsDetected = { timestamp: Date.now(), detections: [ { className, score: 1, } ], }; sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od); } else { ret.emit('event', OnvifEvent.MotionStop); } }); ret.on('close', () => killed = true); ret.on('error', () => killed = true); return ret; } const events = new EventEmitter(); const ret: Destroyable = { on: function (eventName: string | symbol, listener: (...args: any[]) => void): void { events.on(eventName, listener); }, destroy: function (): void { killed = true; }, emit: function (eventName: string | symbol, ...args: any[]): boolean { return events.emit(eventName, ...args); } }; const triggerMotion = () => { this.motionDetected = true; clearTimeout(this.motionTimeout); this.motionTimeout = setTimeout(() => this.motionDetected = false, this.storageSettings.values.motionTimeout * 1000); }; (async () => { while (!killed) { try { // Battey cameras do not have AI state, they just send events in case of PIR sensor triggered // which equals a motion detected if (this.hasPirEvents()) { const { value, data } = await client.getEvents(); if (!!value?.other?.alarm_state) triggerMotion(); ret.emit('data', JSON.stringify(data)); } else { const { value, data } = await client.getMotionState(); if (value) triggerMotion(); ret.emit('data', JSON.stringify(data)); } } catch (e) { ret.emit('error', e); } await sleep(1000); } })(); startAI(ret, triggerMotion); return ret; } async takeSmartCameraPicture(options?: RequestPictureOptions): Promise<MediaObject> { return this.createMediaObject(await this.getClient().jpegSnapshot(options?.timeout), 'image/jpeg'); } async getUrlSettings(): Promise<Setting[]> { return [ { key: 'rtspChannel', title: 'Channel Number Override', subgroup: 'Advanced', description: "The channel number to use for snapshots and video. E.g., 0, 1, 2, etc.", placeholder: '0', type: 'number', value: this.getRtspChannel(), }, ...await super.getUrlSettings(), ] } getRtspChannel() { return parseInt(this.storage.getItem('rtspChannel')) || 0; } createRtspMediaStreamOptions(url: string, index: number) { const ret = createRtspMediaStreamOptions(url, index); ret.tool = 'scrypted'; return ret; } addRtspCredentials(rtspUrl: string) { const url = new URL(rtspUrl); if (url.protocol !== 'rtmp:') { url.username = this.storage.getItem('username'); url.password = this.storage.getItem('password') || ''; } else { const params = url.searchParams; for (const [k, v] of Object.entries(this.client.parameters)) { params.set(k, v); } } return url.toString(); } async createVideoStream(vso: UrlMediaStreamOptions): Promise<MediaObject> { await this.client.login(); return super.createVideoStream(vso); } async getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> { this.videoStreamOptions ||= this.getConstructedVideoStreamOptionsInternal().catch(e => { this.constructedVideoStreamOptions = undefined; throw e; }); return this.videoStreamOptions; } async getConstructedVideoStreamOptionsInternal(): Promise<UrlMediaStreamOptions[]> { let deviceInfo: DevInfo; try { const client = this.getClient(); deviceInfo = await client.getDeviceInfo(); } catch (e) { this.console.error("Unable to gather device information.", e); } let encoderConfig: Enc; try { const client = this.getClient(); encoderConfig = await client.getEncoderConfiguration(); } catch (e) { this.console.error("Codec query failed. Falling back to known defaults.", e); } const rtspChannel = this.getRtspChannel(); const channel = (rtspChannel + 1).toString().padStart(2, '0'); const streams: UrlMediaStreamOptions[] = [ { name: '', id: 'main.bcs', container: 'rtmp', video: { width: 2560, height: 1920 }, url: '' }, { name: '', id: 'ext.bcs', container: 'rtmp', video: { width: 896, height: 672 }, url: '' }, { name: '', id: 'sub.bcs', container: 'rtmp', video: { width: 640, height: 480 }, url: '' }, { name: '', id: `h264Preview_${channel}_main`, container: 'rtsp', video: { codec: 'h264', width: 2560, height: 1920 }, url: '' }, { name: '', id: `h264Preview_${channel}_sub`, container: 'rtsp', video: { codec: 'h264', width: 640, height: 480 }, url: '' } ]; // abilityChn->live // 0: not support // 1: support main/extern/sub stream // 2: support main/sub stream const live = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[rtspChannel]?.live?.ver; const [rtmpMain, rtmpExt, rtmpSub, rtspMain, rtspSub] = streams; streams.splice(0, streams.length); // abilityChn->mainEncType // 0: main stream enc type is H264 // 1: main stream enc type is H265 // anecdotally, encoders of type h265 do not have a working RTMP main stream. const mainEncType = this.storageSettings.values.abilities?.value?.Ability?.abilityChn?.[rtspChannel]?.mainEncType?.ver; if (live === 2) { if (mainEncType === 1) { streams.push(rtmpSub, rtspMain, rtspSub); } else { streams.push(rtmpMain, rtmpSub, rtspMain, rtspSub); } } else if (mainEncType === 1) { streams.push(rtmpExt, rtmpSub, rtspMain, rtspSub); } else { streams.push(rtmpMain, rtmpExt, rtmpSub, rtspMain, rtspSub); } // https://github.com/starkillerOG/reolink_aio/blob/main/reolink_aio/api.py#L93C1-L97C2 // single motion models have 2*2 RTSP channels if (deviceInfo?.model && [ "Reolink TrackMix PoE", "Reolink TrackMix WiFi", "RLC-81MA", "Trackmix Series W760" ].includes(deviceInfo?.model)) { streams.push({ name: '', id: 'autotrack.bcs', container: 'rtmp', video: { width: 896, height: 512 }, url: '', }); if (rtspChannel === 0) { streams.push({ name: '', id: `h264Preview_02_main`, container: 'rtsp', video: { codec: 'h264', width: 3840, height: 2160 }, url: '' }, { name: '', id: `h264Preview_02_sub`, container: 'rtsp', video: { codec: 'h264', width: 640, height: 480 }, url: '' }) } } for (const stream of streams) { var streamUrl; if (stream.container === 'rtmp') { streamUrl = new URL(`rtmp://${this.getRtmpAddress()}/bcs/channel${rtspChannel}_${stream.id}`) const params = streamUrl.searchParams; params.set("channel", rtspChannel.toString()) params.set("stream", '0') stream.url = streamUrl.toString(); stream.name = `RTMP ${stream.id}`; } else if (stream.container === 'rtsp') { streamUrl = new URL(`rtsp://${this.getRtspAddress()}/${stream.id}`) stream.url = streamUrl.toString(); stream.name = `RTSP ${stream.id}`; } } if (encoderConfig) { const { mainStream } = encoderConfig; if (mainStream?.width && mainStream?.height) { for (const stream of streams) { if (stream.id === 'main.bcs' || stream.id === `h264Preview_${channel}_main`) { stream.video.width = mainStream.width; stream.video.height = mainStream.height; } // 4k h265 rtmp is seemingly nonfunctional, but rtsp works. swap them so there is a functional stream. if (mainStream.vType === 'h265' || mainStream.vType === 'hevc') { if (stream.id === `h264Preview_${channel}_main`) { this.console.warn('Detected h265. Change the camera configuration to use 2k mode to force h264. https://docs.scrypted.app/camera-preparation.html#h-264-video-codec'); stream.video.codec = 'h265'; stream.id = `h265Preview_${channel}_main`; stream.name = `RTSP ${stream.id}`; stream.url = `rtsp://${this.getRtspAddress()}/${stream.id}`; // Per Reolink: // https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player/ // Note: the 4k cameras connected with the 4k NVR system will only show a fluent live stream instead of the clear live stream due to the H.264+(h.265) limit. } } } } } return streams; } async putSetting(key: string, value: string) { this.client = undefined; if (this.storageSettings.keys[key]) { await this.storageSettings.putSetting(key, value); } else { await super.putSetting(key, value); } this.updateDevice(); this.updateDeviceInfo(); } showRtspUrlOverride() { return false; } async getRtspPortOverrideSettings(): Promise<Setting[]> { return [ ...await super.getRtspPortOverrideSettings(), ]; } async getOtherSettings(): Promise<Setting[]> { const ret = await super.getOtherSettings(); return [ ...await this.storageSettings.getSettings(), ...ret, ]; } getRtmpAddress() { return `${this.getIPAddress()}:${this.storage.getItem('rtmpPort') || 1935}`; } async reportDevices() { const hasSiren = this.hasSiren(); const hasFloodlight = this.hasFloodlight(); const hasPirSensor = this.hasPirSensor(); const devices: Device[] = []; if (hasSiren) { const sirenNativeId = `${this.nativeId}-siren`; const sirenDevice: Device = { providerNativeId: this.nativeId, name: `${this.name} Siren`, nativeId: sirenNativeId, info: { ...this.info, }, interfaces: [ ScryptedInterface.OnOff ], type: ScryptedDeviceType.Siren, }; devices.push(sirenDevice); } if (hasFloodlight) { const floodlightNativeId = `${this.nativeId}-floodlight`; const floodlightDevice: Device = { providerNativeId: this.nativeId, name: `${this.name} Floodlight`, nativeId: floodlightNativeId, info: { ...this.info, }, interfaces: [ ScryptedInterface.OnOff ], type: ScryptedDeviceType.Light, }; devices.push(floodlightDevice); } if (hasPirSensor) { const pirNativeId = `${this.nativeId}-pir`; const pirDevice: Device = { providerNativeId: this.nativeId, name: `${this.name} PIR sensor`, nativeId: pirNativeId, info: { ...this.info, }, interfaces: [ ScryptedInterface.OnOff ], type: ScryptedDeviceType.Switch, }; devices.push(pirDevice); } sdk.deviceManager.onDevicesChanged({ providerNativeId: this.nativeId, devices }); } async getDevice(nativeId: string): Promise<any> { if (nativeId.endsWith('-siren')) { this.siren ||= new ReolinkCameraSiren(this, nativeId); return this.siren; } else if (nativeId.endsWith('-floodlight')) { this.floodlight ||= new ReolinkCameraFloodlight(this, nativeId); return this.floodlight; } else if (nativeId.endsWith('-pir')) { this.pirSensor ||= new ReolinkCameraPirSensor(this, nativeId); return this.pirSensor; } } async releaseDevice(id: string, nativeId: string) { if (nativeId.endsWith('-siren')) { delete this.siren; } else if (nativeId.endsWith('-floodlight')) { delete this.floodlight; } else if (nativeId.endsWith('-pir')) { delete this.pirSensor; } } } class ReolinkProvider extends RtspProvider { getScryptedDeviceCreator(): string { return 'Reolink Camera'; } getAdditionalInterfaces() { return [ ScryptedInterface.Reboot, ScryptedInterface.VideoCameraConfiguration, ScryptedInterface.Camera, ScryptedInterface.AudioSensor, ScryptedInterface.MotionSensor, ScryptedInterface.VideoTextOverlays, ]; } async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> { const httpAddress = `${settings.ip}:${settings.httpPort || 80}`; let info: DeviceInformation = {}; const skipValidate = settings.skipValidate?.toString() === 'true'; const username = settings.username?.toString(); const password = settings.password?.toString(); let doorbell: boolean = false; let name: string = 'Reolink Camera'; let deviceInfo: DevInfo; let ai; let abilities; const rtspChannel = parseInt(settings.rtspChannel?.toString()) || 0; if (!skipValidate) { const api = new ReolinkCameraClient(httpAddress, username, password, rtspChannel, this.console); const apiWithToken = new ReolinkCameraClient(httpAddress, username, password, rtspChannel, this.console, true); try { await api.jpegSnapshot(); } catch (e) { this.console.error('Error adding Reolink camera', e); throw e; } try { deviceInfo = await api.getDeviceInfo(); doorbell = deviceInfo.type === 'BELL'; name = deviceInfo.name ?? 'Reolink Camera'; ai = await api.getAiState(); try { abilities = await api.getAbility(); } catch (e) { abilities = await apiWithToken.getAbility(); } } catch (e) { this.console.error('Reolink camera does not support AI events', e); } } settings.newCamera ||= name; nativeId = await super.createDevice(settings, nativeId); const device = await this.getDevice(nativeId) as ReolinkCamera; device.info = info; device.putSetting('username', username); device.putSetting('password', password); device.storageSettings.values.doorbell = doorbell; device.storageSettings.values.deviceInfo = deviceInfo; device.storageSettings.values.abilities = abilities; device.storageSettings.values.hasObjectDetector = ai; device.setIPAddress(settings.ip?.toString()); device.putSetting('rtspChannel', settings.rtspChannel?.toString()); device.setHttpPortOverride(settings.httpPort?.toString()); 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', }, { subgroup: 'Advanced', key: 'rtspChannel', title: 'Channel Number Override', description: "Optional: The channel number to use for snapshots and video. E.g., 0, 1, 2, etc.", placeholder: '0', type: 'number', }, { subgroup: 'Advanced', key: 'httpPort', title: 'HTTP Port', description: 'Optional: Override the HTTP Port from the default value of 80.', placeholder: '80', }, { 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 ReolinkCamera(nativeId, this); } } export default ReolinkProvider;