diff --git a/plugins/reolink/src/main.ts b/plugins/reolink/src/main.ts index 2b3c02fc6..1f0a6c977 100644 --- a/plugins/reolink/src/main.ts +++ b/plugins/reolink/src/main.ts @@ -1,5 +1,5 @@ import { sleep } from '@scrypted/common/src/sleep'; -import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, PanTiltZoom, PanTiltZoomCommand, PictureOptions, Reboot, RequestPictureOptions, ScryptedDeviceType, ScryptedInterface, Setting } from "@scrypted/sdk"; +import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, DeviceProvider, Device, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, PictureOptions, Reboot, RequestPictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting } from "@scrypted/sdk"; import { StorageSettings } from '@scrypted/sdk/storage-settings'; import { EventEmitter } from "stream"; import { Destroyable, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; @@ -8,12 +8,46 @@ import { listenEvents } from './onvif-events'; import { OnvifIntercom } from './onvif-intercom'; import { AIState, DevInfo, Enc, ReolinkCameraClient } from './reolink-api'; -class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom, ObjectDetector, PanTiltZoom { +class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff { + intervalId: NodeJS.Timeout; + + constructor(public camera: ReolinkCamera, nativeId: string) { + super(nativeId); + } + + async turnOff() { + await this.setSiren(false); + } + + async turnOn() { + await this.setSiren(true); + } + + private async setSiren(on: boolean) { + // doorbell doesn't seem to support alarm_mode = 'manul', so let's pump the API every second and run the siren in timed mode. + if (this.camera.storageSettings.values.doorbell) { + if (!on) { + clearInterval(this.intervalId); + return; + } + this.intervalId = setInterval(async () => { + const api = this.camera.getClient(); + await api.setSiren(on, 1); + }, 1000); + return; + } + const api = this.camera.getClient(); + await api.setSiren(on); + } +} + +class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, Reboot, Intercom, ObjectDetector, PanTiltZoom { client: ReolinkCameraClient; onvifClient: OnvifCameraAPI; onvifIntercom = new OnvifIntercom(this); videoStreamOptions: Promise; motionTimeout: NodeJS.Timeout; + siren: ReolinkCameraSiren; storageSettings = new StorageSettings(this, { doorbell: { @@ -55,6 +89,10 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom, json: true, hide: true }, + abilities: { + json: true, + hide: true + }, useOnvifDetections: { subgroup: 'Advanced', title: 'Use ONVIF for Object Detection', @@ -162,6 +200,9 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom, if (this.storageSettings.values.hasObjectDetector) { interfaces.push(ScryptedInterface.ObjectDetector); } + if (this.storageSettings.values.abilities?.Ability?.supportAudioAlarm?.ver !== 0) { + interfaces.push(ScryptedInterface.DeviceProvider); + } await this.provider.updateDevice(this.nativeId, name, interfaces, type); } @@ -482,7 +523,6 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom, } return streams; - } async putSetting(key: string, value: string) { @@ -511,6 +551,44 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, Reboot, Intercom, getRtmpAddress() { return `${this.getIPAddress()}:${this.storage.getItem('rtmpPort') || 1935}`; } + + createSiren() { + const sirenNativeId = `${this.nativeId}-siren`; + this.siren = new ReolinkCameraSiren(this, sirenNativeId); + + const sirenDevice: Device = { + providerNativeId: this.nativeId, + name: 'Reolink Siren', + nativeId: sirenNativeId, + info: { + manufacturer: 'Reolink', + serialNumber: this.nativeId, + }, + interfaces: [ + ScryptedInterface.OnOff + ], + type: ScryptedDeviceType.Siren, + }; + sdk.deviceManager.onDevicesChanged({ + providerNativeId: this.nativeId, + devices: [sirenDevice] + }); + + return sirenNativeId; + } + + async getDevice(nativeId: string): Promise { + if (nativeId.endsWith('-siren')) { + return this.siren; + } + throw new Error(`${nativeId} is unknown`); + } + + async releaseDevice(id: string, nativeId: string) { + if (nativeId.endsWith('-siren')) { + delete this.siren; + } + } } class ReolinkProvider extends RtspProvider { @@ -534,6 +612,7 @@ class ReolinkProvider extends RtspProvider { let name: string = 'Reolink Camera'; let deviceInfo: DevInfo; let ai; + let abilities; const skipValidate = settings.skipValidate?.toString() === 'true'; const rtspChannel = parseInt(settings.rtspChannel?.toString()) || 0; if (!skipValidate) { @@ -551,6 +630,7 @@ class ReolinkProvider extends RtspProvider { doorbell = deviceInfo.type === 'BELL'; name = deviceInfo.name ?? 'Reolink Camera'; ai = await api.getAiState(); + abilities = await api.getAbility(); } catch (e) { this.console.error('Reolink camera does not support AI events', e); @@ -566,11 +646,18 @@ class ReolinkProvider extends RtspProvider { device.putSetting('password', password); device.putSetting('doorbell', doorbell.toString()) 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(); + + if (abilities?.Ability?.supportAudioAlarm?.ver !== 0) { + const sirenNativeId = device.createSiren(); + this.devices.set(sirenNativeId, device.siren); + } + return nativeId; } @@ -613,6 +700,13 @@ class ReolinkProvider extends RtspProvider { } createCamera(nativeId: string) { + if (nativeId.endsWith('-siren')) { + const camera = this.devices.get(nativeId.replace(/-siren/, '')) as ReolinkCamera; + if (!camera.siren) { + camera.siren = new ReolinkCameraSiren(camera, nativeId); + } + return camera.siren; + } return new ReolinkCamera(nativeId, this); } } diff --git a/plugins/reolink/src/reolink-api.ts b/plugins/reolink/src/reolink-api.ts index 9ddc9434a..5b8c4a54f 100644 --- a/plugins/reolink/src/reolink-api.ts +++ b/plugins/reolink/src/reolink-api.ts @@ -59,6 +59,10 @@ export type AIState = { channel: number; }; +export type SirenResponse = { + rspCode: number; +} + export class ReolinkCameraClient { credential: AuthFetchCredentialState; @@ -127,6 +131,23 @@ export class ReolinkCameraClient { }; } + async getAbility() { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'GetAbility'); + params.set('channel', this.channelId.toString()); + params.set('user', this.username); + params.set('password', this.password); + const response = await this.request({ + url, + responseType: 'json', + }); + return { + value: response.body?.[0]?.value || response.body?.value, + data: response.body, + }; + } + async jpegSnapshot(timeout = 10000) { const url = new URL(`http://${this.host}/cgi-bin/api.cgi`); const params = url.searchParams; @@ -247,4 +268,51 @@ export class ReolinkCameraClient { await this.ptzOp(op); } } + + async setSiren(on: boolean, duration?: number) { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'AudioAlarmPlay'); + params.set('user', this.username); + params.set('password', this.password); + const createReadable = (data: any) => { + const pt = new PassThrough(); + pt.write(Buffer.from(JSON.stringify(data))); + pt.end(); + return pt; + } + + let alarmMode; + if (duration) { + alarmMode = { + alarm_mode: 'times', + times: duration + }; + } + else { + alarmMode = { + alarm_mode: 'manul', + manual_switch: on? 1 : 0 + }; + } + + const response = await this.request({ + url, + method: 'POST', + responseType: 'json', + }, createReadable([ + { + cmd: "AudioAlarmPlay", + action: 0, + param: { + channel: this.channelId, + ...alarmMode + } + }, + ])); + return { + value: (response.body?.[0]?.value || response.body?.value) as SirenResponse, + data: response.body, + }; + } }