diff --git a/plugins/openvino/src/predict/clip.py b/plugins/openvino/src/predict/clip.py index b413be47d..67d6e054b 100644 --- a/plugins/openvino/src/predict/clip.py +++ b/plugins/openvino/src/predict/clip.py @@ -15,6 +15,8 @@ class ClipEmbedding(PredictPlugin, scrypted_sdk.TextEmbedding, scrypted_sdk.Imag def __init__(self, plugin: PredictPlugin, nativeId: str): super().__init__(nativeId=nativeId, plugin=plugin) + hf_id = "openai/clip-vit-base-patch32" + self.inputwidth = 224 self.inputheight = 224 @@ -23,10 +25,35 @@ class ClipEmbedding(PredictPlugin, scrypted_sdk.TextEmbedding, scrypted_sdk.Imag self.minThreshold = 0.5 self.model = self.initModel() - self.processor = CLIPProcessor.from_pretrained( - "openai/clip-vit-base-patch32", - cache_dir=os.path.join(os.environ["SCRYPTED_PLUGIN_VOLUME"], "files", "hf"), - ) + cache_dir = os.path.join(os.environ["SCRYPTED_PLUGIN_VOLUME"], "files", "hf") + os.makedirs(cache_dir, exist_ok=True) + + self.processor = None + print("Loading CLIP processor from local cache.") + try: + self.processor = CLIPProcessor.from_pretrained( + hf_id, + cache_dir=cache_dir, + local_files_only=True, + ) + print("Loaded CLIP processor from local cache.") + except Exception: + print("CLIP processor not available in local cache yet.") + + asyncio.ensure_future(self.refreshClipProcessor(hf_id, cache_dir), loop=self.loop) + + async def refreshClipProcessor(self, hf_id: str, cache_dir: str): + try: + print("Refreshing CLIP processor cache (online).") + processor = await asyncio.to_thread( + CLIPProcessor.from_pretrained, + hf_id, + cache_dir=cache_dir, + ) + self.processor = processor + print("Refreshed CLIP processor cache.") + except Exception: + print("CLIP processor cache refresh failed.") def getFiles(self): pass diff --git a/plugins/reolink/src/main.ts b/plugins/reolink/src/main.ts index 83dc83c7e..0809c7711 100644 --- a/plugins/reolink/src/main.ts +++ b/plugins/reolink/src/main.ts @@ -8,6 +8,8 @@ import { listenEvents } from './onvif-events'; import { OnvifIntercom } from './onvif-intercom'; import { DevInfo } from './probe'; import { AIState, Enc, isDeviceHomeHub, isDeviceNvr, ReolinkCameraClient } from './reolink-api'; +import { ReolinkNvrDevice } from './nvr/nvr'; +import { ReolinkNvrClient } from './nvr/api'; class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff { sirenTimeout: NodeJS.Timeout; @@ -1134,8 +1136,10 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R } class ReolinkProvider extends RtspProvider { + nvrDevices = new Map(); + getScryptedDeviceCreator(): string { - return 'Reolink Camera'; + return 'Reolink Camera/NVR'; } getAdditionalInterfaces() { @@ -1149,10 +1153,31 @@ class ReolinkProvider extends RtspProvider { ]; } + getDevice(nativeId: string) { + if (nativeId.endsWith('-reolink-nvr')) { + let ret = this.nvrDevices.get(nativeId); + if (!ret) { + ret = new ReolinkNvrDevice(nativeId, this); + if (ret) + this.nvrDevices.set(nativeId, ret); + } + + return ret; + } else { + return super.getDevice(nativeId); + } + } + async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise { const httpAddress = `${settings.ip}:${settings.httpPort || 80}`; let info: DeviceInformation = {}; + const isNvr = settings.isNvr?.toString() === 'true'; + + if (isNvr) { + return this.createNvrDeviceFromSettings(settings); + } + const skipValidate = settings.skipValidate?.toString() === 'true'; const username = settings.username?.toString(); const password = settings.password?.toString(); @@ -1224,6 +1249,12 @@ class ReolinkProvider extends RtspProvider { title: 'IP Address', placeholder: '192.168.2.222', }, + { + key: 'isNvr', + title: 'Is NVR', + description: 'Set if adding a Reolink NVR device. This will allow adding cameras connected to the NVR.', + type: 'boolean', + }, { subgroup: 'Advanced', key: 'rtspChannel', @@ -1252,6 +1283,49 @@ class ReolinkProvider extends RtspProvider { createCamera(nativeId: string) { return new ReolinkCamera(nativeId, this); } + + async createNvrDeviceFromSettings(settings: DeviceCreatorSettings) { + const username = settings.username?.toString(); + const password = settings.password?.toString(); + const ip = settings.ip?.toString(); + const httpPort = settings.httpPort; + const rtspPort = settings.rtspPort; + const httpAddress = `${ip}:${httpPort || 80}`; + + const client = new ReolinkNvrClient(httpAddress, username, password, this.console); + const { devInfo } = await client.getHubInfo(); + + if (!devInfo) { + throw new Error('Unable to connect to Reolink NVR. Please verify the IP address, port, username, and password are correct.'); + } + + const { detail, name } = devInfo; + const nativeId = `${detail}-reolink-nvr`; + + await sdk.deviceManager.onDeviceDiscovered({ + nativeId, + name, + interfaces: [ + ScryptedInterface.Settings, + ScryptedInterface.DeviceDiscovery, + ScryptedInterface.DeviceProvider, + ScryptedInterface.Reboot, + ], + type: ScryptedDeviceType.API, + }); + + const nvrDevice = this.getDevice(nativeId); + + nvrDevice.storageSettings.values.ipAddress = ip; + nvrDevice.storageSettings.values.username = username; + nvrDevice.storageSettings.values.password = password; + nvrDevice.storageSettings.values.httpPort = httpPort; + nvrDevice.storageSettings.values.rtspPort = rtspPort; + + nvrDevice.updateDeviceInfo(devInfo); + + return nativeId; + } } export default ReolinkProvider; diff --git a/plugins/reolink/src/nvr/api.ts b/plugins/reolink/src/nvr/api.ts new file mode 100644 index 000000000..68dd52c30 --- /dev/null +++ b/plugins/reolink/src/nvr/api.ts @@ -0,0 +1,1126 @@ +import { AuthFetchCredentialState, authHttpFetch, HttpFetchOptions } from '@scrypted/common/src/http-auth-fetch'; +import { PassThrough, Readable } from 'stream'; +import { sleep } from "@scrypted/common/src/sleep"; +import { PanTiltZoomCommand, VideoClipOptions } from "@scrypted/sdk"; +import { DevInfo, getLoginParameters } from '../probe'; +import { ReolinkNvrDevice } from './nvr'; + +type StoredLoginSession = { + host: string; + username: string; + /** Querystring auth params expected by Reolink API (token OR user/password). */ + parameters: Record; + /** Epoch ms when session was obtained (or last confirmed valid). */ + createdAt: number; + /** Token lease time in seconds. `0` means non-expiring/unknown; omit if unknown. */ + leaseTimeSeconds?: number; +}; + +export interface DeviceInputData { + hasBattery: boolean, + hasPirEvents: boolean, + hasFloodlight: boolean, + hasPtz: boolean, + sleeping: boolean, +}; +export interface EventsResponse { motion: boolean, objects: string[], entries: any[] }; +export interface DeviceInfoResponse { + channelStatus?: any, + ai?: any, + channelInfo?: any, + enc?: any, + entries: any[] +}; +export interface BatteryInfoResponse { batteryLevel: number, sleeping: boolean, entries: any[] }; +export interface DeviceStatusResponse { + floodlightEnabled?: boolean, + pirEnabled?: boolean, + ptzPresets?: any[], + osd?: any[], + entries: any[] +}; + +export interface Enc { + audio: number; + channel: number; + mainStream: Stream; + subStream: Stream; +} + +export interface Stream { + bitRate: number; + frameRate: number; + gop: number; + height: number; + profile: string; + size: string; + vType: string; + width: number; +} + +export interface PurpleOsdChannel { + enable: number; + name: string; + pos: string; +} + +export interface PurpleOsdTime { + enable: number; + pos: string; +} +export interface InitialOsd { + bgcolor: number; + channel: number; + osdChannel: PurpleOsdChannel; + osdTime: PurpleOsdTime; + watermark: number; +} + +export interface Initial { + Osd: InitialOsd; +} + +export interface Osd { + cmd: string; + code: number; + initial: Initial; + range: Range; + value: Initial; +} + + +export interface AIDetectionState { + alarm_state: number; + support: number; +} + +type AiKey = 'dog_cat' | 'face' | 'other' | 'package' | 'people'; + +export type AIState = Partial> & { + channel: number; +}; + +export type SirenResponse = { + rspCode: number; +} + +export interface PtzPreset { + id: number; + name: string; +} + +export class ReolinkNvrClient { + credential: AuthFetchCredentialState; + parameters: Record; + tokenLease: number; + loggingIn = false; + loggedIn = false; + rebooting = false; + connectionTime = Date.now(); + console: Console; + host: string; + + maxSessionsCount = 0; + loginFirstCount = 0; + + constructor( + httpAddress: string, + username: string, + password: string, + console: Console, + public nvrDevice?: ReolinkNvrDevice + ) { + this.credential = { + username, + password, + }; + this.host = httpAddress; + this.console = console; + } + + private async request(options: HttpFetchOptions, body?: Readable) { + const response = await authHttpFetch({ + ...options, + rejectUnauthorized: false, + credential: this.credential, + body, + }); + return response; + } + + private createReadable = (data: any) => { + const pt = new PassThrough(); + pt.write(Buffer.from(JSON.stringify(data))); + pt.end(); + return pt; + } + + private getStoredLoginSession(): StoredLoginSession | undefined { + const stored = this.nvrDevice?.storageSettings?.values?.loginSession as StoredLoginSession; + if (!stored || typeof stored !== 'object') + return; + if (!stored.host || !stored.username || !stored.parameters || typeof stored.parameters !== 'object') + return; + return stored; + } + + private setStoredLoginSession(session: StoredLoginSession | undefined) { + if (this.nvrDevice) { + this.nvrDevice.storageSettings.values.loginSession = session; + } + } + + private computeTokenLease(createdAt: number, leaseTimeSeconds?: number) { + if (!leaseTimeSeconds || leaseTimeSeconds <= 0) + return Infinity; + return createdAt + leaseTimeSeconds * 1000; + } + + private async validateExistingSession(parameters: Record) { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'GetDevInfo'); + for (const [k, v] of Object.entries(parameters)) { + params.set(k, v); + } + + const response = await this.request({ + url, + responseType: 'json', + }); + + const error = response?.body?.[0]?.error; + if (error) + return false; + + const devInfo: DevInfo = response?.body?.[0]?.value?.DevInfo; + return !!(devInfo?.type || devInfo?.model || devInfo?.exactType); + } + + async login() { + const now = Date.now(); + if (this.parameters && this.tokenLease && this.tokenLease > now) { + return; + } + + if (this.loggingIn) { + while (this.loggingIn) { + await sleep(50); + } + if (this.parameters && this.tokenLease && this.tokenLease > Date.now()) { + return; + } + } + + this.loggingIn = true; + try { + // 1) Try restore from storageSettings first (if still valid). + const stored = this.getStoredLoginSession(); + if (stored + && stored.host === this.host + && stored.username === this.credential.username + && stored.parameters + && Object.keys(stored.parameters).length) { + const tokenLease = this.computeTokenLease(stored.createdAt, stored.leaseTimeSeconds); + const leaseStillValid = tokenLease === Infinity || tokenLease > now; + + if (leaseStillValid) { + try { + const ok = await this.validateExistingSession(stored.parameters); + if (ok) { + this.console.log('Restored previous authentication session'); + this.parameters = stored.parameters; + this.tokenLease = tokenLease; + this.loggedIn = true; + this.connectionTime = now; + // Refresh timestamp so we don't churn sessions on long runtimes. + this.setStoredLoginSession({ + ...stored, + createdAt: now, + }); + return; + } + } + catch (e) { + // Validation failed; fall through to full login. + } + } + } + + // 2) Create a new session. + if (!this.tokenLease || !this.parameters) { + this.console.log(`Creating authentication session`); + } else { + this.console.log(`Token expired at ${new Date(this.tokenLease).toISOString()}, renewing`); + } + + const { parameters, leaseTimeSeconds } = await getLoginParameters( + this.host, + this.credential.username, + this.credential.password, + true + ); + + this.parameters = parameters; + this.tokenLease = this.computeTokenLease(now, leaseTimeSeconds); + this.loggedIn = true; + this.connectionTime = now; + + this.setStoredLoginSession({ + host: this.host, + username: this.credential.username, + parameters, + createdAt: now, + leaseTimeSeconds: (!leaseTimeSeconds || leaseTimeSeconds === Infinity) ? 0 : leaseTimeSeconds, + }); + } + finally { + this.loggingIn = false; + } + } + + async checkErrors() { + if (this.rebooting) { + return; + } + + if (Date.now() - this.connectionTime > 1000 * 60 * 60 || this.loginFirstCount > 5) { + this.console.log('Reconnecting') + await this.reconnect(); + } else if (this.maxSessionsCount > 5) { + await this.reboot(); + } + } + + async requestWithLogin(options: HttpFetchOptions, body?: Readable) { + await this.login(); + if (!this.parameters) { + return; + } + + if (this.rebooting) { + return; + } + + const url = options.url as URL; + const params = url.searchParams; + for (const [k, v] of Object.entries(this.parameters)) { + params.set(k, v); + } + const res = await this.request(options, body); + const errors = res?.body?.filter(elem => elem.error).map(elem => elem.error); + + if (errors.length) { + for (const error of errors) { + const code = error.rspCode; + if ([-6].includes(code)) { + this.loginFirstCount++; + } else if ([-5].includes(code)) { + this.maxSessionsCount++; + } else { + this.maxSessionsCount = 0; + this.loginFirstCount = 0; + } + } + } + + return res; + } + + async reboot() { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'Reboot'); + this.rebooting = true; + const response = await this.requestWithLogin({ + url, + responseType: 'json', + }); + + // Wait 1 minute, supposed to be ready + setTimeout(() => { + this.rebooting = false; + this.maxSessionsCount = 0; + this.loginFirstCount = 0; + }, 1000 * 60); + + return { + value: response?.body?.[0]?.value?.rspCode, + data: response?.body, + }; + } + + async logout() { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [ + { + cmd: "Logout", + }, + ]; + + await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + this.tokenLease = undefined; + this.parameters = {}; + this.setStoredLoginSession(undefined); + } + + async reconnect() { + await this.logout(); + await this.login(); + } + + async getOsd(channel: number): Promise { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [ + { + cmd: "GetOsd", + action: 1, + param: { channel } + }, + ]; + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + const error = response?.body?.find(elem => elem.error)?.error; + if (error) { + this.console.error('error during call to getOsd', error); + } + + return response?.body?.[0] as Osd; + } + + async setOsd(channel: number, osd: Osd) { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [ + { + cmd: "SetOsd", + param: { + Osd: { + channel, + osdChannel: osd.value.Osd.osdChannel, + osdTime: osd.value.Osd.osdTime, + } + } + } + ]; + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + const error = response?.body?.find(elem => elem.error)?.error; + if (error) { + this.console.error('error during call to getOsd', error); + } + } + + printErrors(response: any, action: string, body: any[]) { + const errors = response?.body?.filter(elem => elem.error).map(elem => ({ ...elem.error, cmd: elem.cmd })); + if (errors.length) { + this.console.error(`error during call to ${action}`, JSON.stringify({ errors, body })); + } + } + + async getHubInfo() { + const url = new URL(`http://${this.host}/api.cgi`); + const body = [ + { + cmd: "GetAbility", + action: 0, + param: { User: { userName: this.credential.username } } + }, + { + cmd: "GetDevInfo", + action: 0, + param: {} + } + ]; + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + this.printErrors(response, 'getHubInfo', body); + + const abilities = response.body.find(item => item.cmd === 'GetAbility')?.value; + const hubData = response.body.find(item => item.cmd === 'GetDevInfo')?.value; + const devInfo: DevInfo = hubData?.DevInfo; + + return { + abilities, + hubData, + devInfo, + response: response.body + }; + } + + async jpegSnapshot(channel: number, timeout = 10000) { + const url = new URL(`http://${this.host}/cgi-bin/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'Snap'); + params.set('channel', String(channel)); + params.set('rs', Date.now().toString()); + + const response = await this.requestWithLogin({ + url, + timeout, + }); + + return response?.body; + } + + async getEncoderConfiguration(channel: number): Promise { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'GetEnc'); + params.set('channel', String(channel)); + const response = await this.requestWithLogin({ + url, + responseType: 'json', + }); + + return response?.body?.[0]?.value?.Enc; + } + + private async ptzOp(channel: number, op: string, speed: number, id?: number) { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'PtzCtrl'); + + const c1 = this.requestWithLogin({ + url, + method: 'POST', + responseType: 'text', + }, this.createReadable([ + { + cmd: "PtzCtrl", + param: { + channel, + op, + speed, + timeout: 1, + id + } + }, + ])); + + await sleep(500); + + const c2 = this.requestWithLogin({ + url, + method: 'POST', + }, this.createReadable([ + { + cmd: "PtzCtrl", + param: { + channel, + op: "Stop" + } + }, + ])); + + this.console.log(await c1); + this.console.log(await c2); + } + + private async presetOp(channel: number, speed: number, id: number) { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'PtzCtrl'); + + const c1 = this.requestWithLogin({ + url, + method: 'POST', + responseType: 'text', + }, this.createReadable([ + { + cmd: "PtzCtrl", + param: { + channel, + op: 'ToPos', + speed, + id + } + }, + ])); + } + + async ptz(channel: number, command: PanTiltZoomCommand) { + // reolink doesnt accept signed values to ptz + // in favor of explicit direction. + // so we need to convert the signed values to abs explicit direction. + if (command.preset && !Number.isNaN(Number(command.preset))) { + await this.presetOp(channel, 1, Number(command.preset)); + return; + } + + let op = ''; + if (command.pan < 0) + op += 'Left'; + else if (command.pan > 0) + op += 'Right' + if (command.tilt < 0) + op += 'Down'; + else if (command.tilt > 0) + op += 'Up'; + + if (op) { + await this.ptzOp(channel, op, Math.ceil(Math.abs(command?.pan || command?.tilt || 1) * 10)); + } + + op = undefined; + if (command.zoom < 0) + op = 'ZoomDec'; + else if (command.zoom > 0) + op = 'ZoomInc'; + + if (op) { + await this.ptzOp(channel, op, Math.ceil(Math.abs(command?.zoom || 1) * 10)); + } + } + + async getSiren(channel: number) { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [{ + cmd: 'GetAudioAlarmV20', + action: 0, + param: { channel } + }]; + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable(body)); + + const error = response?.body?.[0]?.error; + if (error) { + this.console.error('error during call to getSiren', JSON.stringify(body), error); + } + + return { + enabled: response?.body?.[0]?.value?.Audio?.enable === 1 + }; + } + + async setSiren(channel: number, on: boolean, duration?: number) { + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'AudioAlarmPlay'); + + let alarmMode; + if (duration) { + alarmMode = { + alarm_mode: 'times', + times: duration + }; + } + else { + alarmMode = { + alarm_mode: 'manul', + manual_switch: on ? 1 : 0 + }; + } + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable([ + { + cmd: "AudioAlarmPlay", + action: 0, + param: { + channel, + ...alarmMode + } + }, + ])); + return { + value: (response?.body?.[0]?.value || response?.body?.value) as SirenResponse, + data: response?.body, + }; + } + + async setWhiteLedState(channel: number, on?: boolean, brightness?: number) { + const url = new URL(`http://${this.host}/api.cgi`); + + const settings: any = { channel }; + + if (on !== undefined) { + settings.state = on ? 1 : 0; + } + + if (brightness !== undefined) { + settings.bright = brightness; + } + + const body = [{ + cmd: 'SetWhiteLed', + param: { WhiteLed: settings } + }]; + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable(body)); + + const error = response?.body?.[0]?.error; + if (error) { + this.console.error('error during call to setWhiteLedState', JSON.stringify(body), error); + } + } + + async getStatusInfo(channelsMap: Map) { + const url = new URL(`http://${this.host}/api.cgi`); + const chanelIndex: Record = {}; + + const body: any[] = []; + + channelsMap.forEach(({ hasFloodlight, hasPirEvents, hasPtz, sleeping }, channel) => { + chanelIndex[channel] = {}; + + if (!sleeping) { + body.push( + { + cmd: "GetOsd", + action: 1, + param: { channel } + } + ); + chanelIndex[channel].osd = body.length - 1; + + if (hasFloodlight) { + body.push( + { + cmd: 'GetWhiteLed', + action: 0, + param: { channel } + }, + ); + chanelIndex[channel].floodlight = body.length - 1; + } + + if (hasPirEvents) { + body.push( + { + cmd: 'GetPirInfo', + action: 0, + param: { channel } + } + ); + chanelIndex[channel].pir = body.length - 1; + } + + if (hasPtz) { + body.push( + { + cmd: "GetPtzPreset", + action: 1, + param: { + channel + } + } + ); + chanelIndex[channel].presets = body.length - 1; + } + } + }); + const channelData: Record = {}; + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + this.printErrors(response, 'getStatusInfo', body); + + + channelsMap.forEach(({ hasFloodlight, hasPirEvents, hasPtz }, channel) => { + const { floodlight, pir, presets, osd } = chanelIndex[channel]; + channelData[channel] = { entries: [] }; + + if (osd !== undefined) { + const osdEntry = response?.body?.[osd]; + channelData[channel].osd = osdEntry; + channelData[channel].entries.push(osdEntry); + } + + if (hasFloodlight && floodlight !== undefined) { + const floodlightEntry = response?.body?.[floodlight]; + channelData[channel].floodlightEnabled = floodlightEntry?.value?.WhiteLed?.state === 1; + channelData[channel].entries.push(floodlightEntry); + + } + + if (hasPirEvents && pir !== undefined) { + const pirEntry = response?.body?.[pir]; + channelData[channel].pirEnabled = pirEntry?.value?.pirInfo?.enable === 1 + channelData[channel].entries.push(pirEntry); + + } + + if (hasPtz && presets !== undefined) { + const ptzPresetsEntry = response?.body?.[presets]; + channelData[channel].ptzPresets = ptzPresetsEntry?.value?.PtzPreset?.filter(preset => preset.enable === 1); + channelData[channel].entries.push(ptzPresetsEntry); + } + }); + + return { + deviceStatusData: channelData, + response: response.body, + }; + } + + async getBatteryInfo(channelsMap: Map) { + const url = new URL(`http://${this.host}/api.cgi`); + const chanelIndex: Record = {}; + + const body: any[] = [ + { + cmd: "GetChannelstatus", + } + ]; + + const channels: number[] = []; + channelsMap.forEach(({ hasBattery }, channel) => { + if (hasBattery) { + channels.push(channel) + } + }); + + for (const channel of channels) { + body.push( + { + cmd: "GetBatteryInfo", + action: 0, + param: { channel } + }, + ); + chanelIndex[channel] = body.length - 1; + } + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + this.printErrors(response, 'getBatteryInfo', body); + + const channelData: Record = {}; + const channelStatusData = response?.body?.[0]; + for (const channel of channels) { + const batteryInfoEntry = response?.body?.[chanelIndex[channel]]?.value?.Battery; + const channelStatusEntry = channelStatusData?.value?.status?.find(elem => elem.channel === channel); + + channelData[channel] = { + entries: [batteryInfoEntry, channelStatusEntry], + batteryLevel: batteryInfoEntry?.batteryPercent, + sleeping: channelStatusEntry?.sleep === 1, + }; + } + + return { + batteryInfoData: channelData, + response: response.body, + }; + } + + async getChannels() { + const url = new URL(`http://${this.host}/api.cgi`); + + const channelsBody = [{ cmd: 'GetChannelstatus' }]; + + const channelsResponse = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(channelsBody)); + + const channels = channelsResponse.body?.[0]?.value?.status + ?.filter(elem => !!elem.uid) + ?.map(elem => elem.channel) + + return { + channels, + channelsResponse + }; + } + + async getEvents(channelsMap: Map) { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = []; + const chanelIndex: Record = {}; + + channelsMap.forEach(({ hasPirEvents }, channel) => { + chanelIndex[channel] = {}; + if (hasPirEvents) { + body.push({ + cmd: 'GetEvents', + action: 0, + param: { channel } + }); + chanelIndex[channel].events = body.length - 1; + } else { + body.push({ + cmd: 'GetMdState', + action: 0, + param: { channel } + }); + chanelIndex[channel].motion = body.length - 1; + body.push({ + cmd: 'GetAiState', + action: 0, + param: { channel } + }); + chanelIndex[channel].events = body.length - 1; + } + }) + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + if (!response) { + return {}; + } + + const channelData: Record = {}; + + const processDetections = (aiResponse: any) => { + const classes: string[] = []; + for (const key of Object.keys(aiResponse ?? {})) { + if (key === 'channel') + continue; + const { alarm_state } = aiResponse[key]; + if (alarm_state) + classes.push(key); + } + + return classes; + } + + channelsMap.forEach(({ hasPirEvents }, channel) => { + const { events, motion } = chanelIndex[channel]; + channelData[channel] = { motion: false, objects: [], entries: [] }; + + if (hasPirEvents) { + const eventsEntry = response?.body?.[events]; + const classes = processDetections(eventsEntry?.value?.ai); + channelData[channel].motion = classes.includes('other') || classes.length > 0; + channelData[channel].objects = classes.filter(cl => cl !== 'other'); + channelData[channel].entries.push(eventsEntry); + } else { + const eventsEntry = response?.body?.[events]; + const motionEntry = response?.body?.[motion]; + const classes = processDetections(eventsEntry?.value); + channelData[channel].motion = motionEntry?.value?.state || classes.length > 0; + channelData[channel].objects = classes.filter(cl => cl !== 'other'); + channelData[channel].entries.push(eventsEntry, motionEntry); + } + }); + + this.printErrors(response, 'getEvents', body); + + return { + parsed: channelData, + response: response.body, + body: response.body + }; + } + + async getDevicesInfo() { + const url = new URL(`http://${this.host}/api.cgi`); + + const { channels, channelsResponse } = await this.getChannels(); + + const body: any[] = []; + + const responseMap: Record = {}; + + for (const channel of channels) { + responseMap[channel] = { + ai: body.length, + chnInfo: body.length + 1, + enc: body.length + 2, + } + body.push( + { cmd: "GetChnTypeInfo", action: 0, param: { channel } }, + { cmd: "GetAiState", action: 0, param: { channel } }, + { cmd: "GetEnc", action: 1, param: { channel } }, + ); + } + + const response = await this.requestWithLogin({ + url, + responseType: 'json', + method: 'POST', + }, this.createReadable(body)); + + const ret: Record = {}; + + let currentChannelIndex = 0; + for (const channel of channels) { + const indexMultilpier = currentChannelIndex * 3; + + const chnInfoItem = response.body[indexMultilpier]; + const aiItem = response.body[indexMultilpier + 1]; + const encItem = response.body[indexMultilpier + 2]; + + const channelStatus = channelsResponse.body?.[0]?.value?.status?.find(item => item?.channel === channel); + + ret[channel] = { + entries: [chnInfoItem, aiItem, encItem], + }; + + !chnInfoItem?.error && (ret[channel].channelInfo = chnInfoItem?.value); + !aiItem?.error && (ret[channel].ai = aiItem?.value); + !encItem?.error && (ret[channel].enc = encItem?.value); + ret[channel].channelStatus = channelStatus; + + currentChannelIndex++; + } + + this.printErrors(response, 'getDevicesInfo', body); + + return { + devicesData: ret, + response: response.body, + channels, + channelsResponse, + requestBody: body, + }; + } + + async getPirState(channel: number) { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [{ + cmd: 'GetPirInfo', + action: 0, + param: { channel } + }]; + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable(body)); + + const error = response?.body?.[0]?.error; + if (error) { + this.console.error('error during call to getPirState', JSON.stringify(body), error); + } + + return { + enabled: response?.body?.[0]?.value?.pirInfo?.enable === 1, + state: response?.body?.[0]?.value?.pirInfo + }; + } + + async setPirState(channel: number, on: boolean) { + const url = new URL(`http://${this.host}/api.cgi`); + + const currentPir = await this.getPirState(channel); + const newState = on ? 1 : 0; + + if (!currentPir || currentPir.state?.enable === newState) { + return; + } + + const pirInfo = { + ...currentPir, + channel, + enable: newState + } + + const body = [{ + cmd: 'SetPirInfo', + action: 0, + param: { pirInfo } + }]; + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable(body)); + + const error = response?.body?.[0]?.error; + if (error) { + this.console.error('error during call to setPirState', JSON.stringify(body), error); + } + } + + async getLocalLink(channel: number) { + const url = new URL(`http://${this.host}/api.cgi`); + + const body = [ + { + cmd: 'GetLocalLink', + action: 0, + param: {} + }, + { + cmd: 'GetWifiSignal', + action: 0, + param: { channel } + }, + ]; + + const response = await this.requestWithLogin({ + url, + method: 'POST', + responseType: 'json', + }, this.createReadable(body)); + + const error = response?.body?.[0]?.error; + if (error) { + this.console.error('error during call to getLocalLink', JSON.stringify(body), error); + } + + const activeLink = response?.body?.find(entry => entry.cmd === 'GetLocalLink') + ?.value?.LocalLink?.activeLink; + const wifiSignal = response?.body?.find(entry => entry.cmd === 'GetWifiSignal') + ?.value?.wifiSignal ?? undefined + + let isWifi = false; + if (wifiSignal !== undefined) { + isWifi = wifiSignal >= 0 && wifiSignal <= 4; + } + + if (!isWifi && activeLink) { + isWifi = activeLink !== 'LAN'; + } + + return { + activeLink, + wifiSignal, + isWifi + }; + } +} \ No newline at end of file diff --git a/plugins/reolink/src/nvr/camera.ts b/plugins/reolink/src/nvr/camera.ts new file mode 100644 index 000000000..045235a5c --- /dev/null +++ b/plugins/reolink/src/nvr/camera.ts @@ -0,0 +1,885 @@ +import { sleep } from '@scrypted/common/src/sleep'; +import sdk, { Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestPictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, Sleep, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk"; +import { StorageSettings } from '@scrypted/sdk/storage-settings'; +import { createRtspMediaStreamOptions, RtspSmartCamera, UrlMediaStreamOptions } from "../../../rtsp/src/rtsp"; +import { connectCameraAPI, OnvifCameraAPI } from '../onvif-api'; +import { OnvifIntercom } from '../onvif-intercom'; +import { AIState, BatteryInfoResponse, DeviceStatusResponse, Enc, EventsResponse } from './api'; +import { ReolinkNvrDevice } from './nvr'; + +export const moToB64 = async (mo: MediaObject) => { + const bufferImage = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'); + return bufferImage?.toString('base64'); +} + +export const b64ToMo = async (b64: string) => { + const buffer = Buffer.from(b64, 'base64'); + return await sdk.mediaManager.createMediaObject(buffer, 'image/jpeg'); +} + +class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff { + sirenTimeout: NodeJS.Timeout; + + constructor(public camera: ReolinkNvrCamera, 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(); + + await api.setSiren(this.camera.getRtspChannel(), on); + } +} + +class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brightness { + constructor(public camera: ReolinkNvrCamera, nativeId: string) { + super(nativeId); + } + + async setBrightness(brightness: number): Promise { + 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.getClient(); + + await api.setWhiteLedState(this.camera.getRtspChannel(), on, brightness); + } +} + +class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff { + constructor(public camera: ReolinkNvrCamera, 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.getClient(); + + await api.setPirState(this.camera.getRtspChannel(), on); + } +} + +export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays { + onvifClient: OnvifCameraAPI; + onvifIntercom = new OnvifIntercom(this); + videoStreamOptions: Promise; + motionTimeout: NodeJS.Timeout; + siren: ReolinkCameraSiren; + floodlight: ReolinkCameraFloodlight; + pirSensor: ReolinkCameraPirSensor; + lastB64Snapshot: string; + lastSnapshotTaken: number; + nvrDevice: ReolinkNvrDevice; + abilities: any; + + storageSettings = new StorageSettings(this, { + debugEvents: { + title: 'Debug Events', + type: 'boolean', + immediate: true, + }, + rtspChannel: { + subgroup: 'Advanced', + title: 'Channel', + type: 'number', + hide: true, + }, + motionTimeout: { + subgroup: 'Advanced', + title: 'Motion Timeout', + defaultValue: 20, + type: 'number', + }, + 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: [], + }, + cachedOsd: { + multiple: true, + hide: true, + json: true, + defaultValue: [], + }, + useOnvifTwoWayAudio: { + subgroup: 'Advanced', + title: 'Use ONVIF for Two-Way Audio', + type: 'boolean', + }, + prebufferSet: { + type: 'boolean', + hide: true + } + }); + + constructor(nativeId: string, nvrDevice: ReolinkNvrDevice) { + super(nativeId, nvrDevice.plugin); + this.nvrDevice = nvrDevice; + + this.storageSettings.settings.presets.onGet = async () => { + const choices = this.storageSettings.values.cachedPresets.map((preset) => preset.id + '=' + preset.name); + return { + choices, + }; + }; + + const channel = Number(this.storageSettings.values.rtspChannel); + if (!Number.isNaN(channel)) { + this.nvrDevice.cameraNativeMap.set(this.nativeId, this); + } + + setTimeout(async () => { + await this.init(); + }, 2000); + } + + public getLogger() { + return this.console; + } + + async init() { + const logger = this.getLogger(); + + while (!this.nvrDevice.client || !this.nvrDevice.client.loggedIn) { + logger.log('Waiting for plugin connection'); + await sleep(3000); + } + + await this.reportDevices(); + this.updateDeviceInfo(); + this.updatePtzCaps(); + + const interfaces = await this.getDeviceInterfaces(); + + const device = { + nativeId: this.nativeId, + providerNativeId: this.nvrDevice.nativeId, + name: this.name, + interfaces, + type: this.type, + info: this.info, + }; + + logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`); + + await sdk.deviceManager.onDeviceDiscovered(device); + // await this.nvrDevice.plugin.updateDevice(this.nativeId, this.name, interfaces, ScryptedDeviceType.Camera); + + if (this.hasBattery() && !this.storageSettings.getItem('prebufferSet')) { + const device = sdk.systemManager.getDeviceById(this.id); + logger.log('Disabling prebbufer for battery cam'); + await device.putSetting('prebuffer:enabledStreams', '[]'); + this.storageSettings.values.prebufferSet = true; + } + } + + getClient() { + return this.nvrDevice.getClient(); + } + + async getVideoTextOverlays(): Promise> { + const client = this.getClient(); + if (!client) { + return; + } + const { cachedOsd } = this.storageSettings.values; + + return { + osdChannel: { + text: cachedOsd.value.Osd.osdChannel.enable ? cachedOsd.value.Osd.osdChannel.name : undefined, + }, + osdTime: { + text: !!cachedOsd.value.Osd.osdTime.enable, + readonly: true, + } + } + } + + async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise { + const client = this.getClient(); + if (!client) { + return; + } + + const osd = await client.getOsd(this.getRtspChannel()); + 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(this.getRtspChannel(), osd); + } + + updatePtzCaps() { + const { hasPanTilt, hasZoom } = this.getPtzCapabilities(); + this.ptzCapabilities = { + ...this.ptzCapabilities, + pan: hasPanTilt, + tilt: hasPanTilt, + zoom: hasZoom, + } + } + + getAbilities() { + if (!this.abilities) { + const channel = Number(this.getRtspChannel()); + this.abilities = this.nvrDevice.storageSettings.values.abilities?.Ability?.abilityChn?.[channel]; + } + + return this.abilities; + } + + getEncoderSettings() { + return this.getDeviceData()?.enc?.Enc; + } + + async getDetectionInput(detectionId: string, eventId?: any): Promise { + return; + } + + async ptzCommand(command: PanTiltZoomCommand): Promise { + const client = this.getClient(); + if (!client) { + return; + } + client.ptz(this.getRtspChannel(), command); + } + + getDeviceData() { + const channel = this.getRtspChannel(); + return this.nvrDevice.storageSettings.values.devicesData?.[channel]; + } + + async getObjectTypes(): Promise { + try { + const deviceData = this.getDeviceData(); + const ai: AIState = deviceData?.ai; + 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 { + 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 { + return this.onvifIntercom.stopIntercom(); + } + + hasSiren() { + const abilities = this.getAbilities(); + const hasAbility = abilities?.supportAudioAlarm; + + return (hasAbility && hasAbility?.ver !== 0); + } + + hasFloodlight() { + const channelData = this.getAbilities(); + + 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; + } + + hasBattery() { + const abilities = this.getAbilities(); + const batteryConfigVer = abilities?.battery?.ver ?? 0; + return batteryConfigVer > 0; + } + + getPtzCapabilities() { + const abilities = this.getAbilities(); + const hasZoom = (abilities?.supportDigitalZoom?.ver ?? 0) > 0; + const hasPanTilt = (abilities?.ptzCtrl?.ver ?? 0) > 0; + const hasPresets = (abilities?.ptzPreset?.ver ?? 0) > 0; + + return { + hasZoom, + hasPanTilt, + hasPresets, + hasPtz: hasZoom || hasPanTilt || hasPresets + }; + } + + hasPtzCtrl() { + const abilities = this.getAbilities(); + const zoomVer = abilities?.supportDigitalZoom?.ver ?? 0; + return zoomVer > 0; + } + + hasPirEvents() { + const abilities = this.getAbilities(); + const pirEvents = abilities?.mdWithPir?.ver ?? 0; + return pirEvents > 0; + } + + async getDeviceInterfaces() { + const interfaces = [ + ScryptedInterface.VideoCamera, + ScryptedInterface.Settings, + ...this.nvrDevice.plugin.getAdditionalInterfaces(), + ]; + + try { + if (this.storageSettings.values.useOnvifTwoWayAudio) { + interfaces.push( + ScryptedInterface.Intercom + ); + } + + const { hasPtz } = this.getPtzCapabilities(); + + if (hasPtz) { + interfaces.push(ScryptedInterface.PanTiltZoom); + } + if ((await this.getObjectTypes()).classes.length > 0) { + interfaces.push(ScryptedInterface.ObjectDetector); + } + if (this.hasSiren() || this.hasFloodlight() || this.hasPirEvents()) + interfaces.push(ScryptedInterface.DeviceProvider); + if (this.hasBattery()) { + interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep); + } + } catch (e) { + this.getLogger().error('Error getting device interfaces', e); + } + + return interfaces; + } + + async processBatteryData(data: BatteryInfoResponse) { + const logger = this.getLogger(); + const { batteryLevel, sleeping } = data; + const { debugEvents } = this.storageSettings.values; + + if (debugEvents) { + logger.debug(`Battery info received: ${JSON.stringify(data)}`); + } + + if (sleeping !== this.sleeping) { + this.sleeping = sleeping; + } + + if (batteryLevel !== this.batteryLevel) { + this.batteryLevel = batteryLevel ?? this.batteryLevel; + } + } + + async processDeviceStatusData(data: DeviceStatusResponse) { + const { floodlightEnabled, pirEnabled, ptzPresets, osd } = data; + const logger = this.getLogger(); + + const { debugEvents } = this.storageSettings.values; + if (debugEvents) { + logger.info(`Device status received: ${JSON.stringify(data)}`); + } + + if (this.floodlight && floodlightEnabled !== this.floodlight.on) { + this.floodlight.on = floodlightEnabled; + } + + if (this.pirSensor && pirEnabled !== this.pirSensor.on) { + this.pirSensor.on = pirEnabled; + } + + if (ptzPresets) { + this.storageSettings.values.cachedPresets = ptzPresets + } + + if (osd) { + this.storageSettings.values.cachedOsd = osd + } + } + + updateDeviceInfo() { + const ip = this.nvrDevice.storageSettings.values.ipAddress + if (!ip) + return; + const info = this.info || {}; + info.ip = ip; + + const deviceData = this.getDeviceData(); + + info.serialNumber = deviceData?.serial; + info.firmware = deviceData?.firmVer; + info.version = deviceData?.boardInfo; + info.model = deviceData?.typeInfo; + info.manufacturer = 'Reolink'; + info.managementUrl = `http://${ip}`; + this.info = info; + } + + async getOnvifClient() { + if (!this.onvifClient) + this.onvifClient = await this.createOnvifClient(); + return this.onvifClient; + } + + createOnvifClient() { + const { username, password, httpPort, ipAddress } = this.nvrDevice.storageSettings.values; + const address = `${ipAddress}:${httpPort}`; + return connectCameraAPI(address, username, password, this.getLogger(), undefined); + } + + async processEvents(events: EventsResponse) { + const logger = this.getLogger(); + + const { debugEvents } = this.storageSettings.values; + if (debugEvents) { + logger.debug(`Events received: ${JSON.stringify(events)}`); + } + + if (events.motion !== this.motionDetected) { + if (events.motion) { + + this.motionDetected = true; + this.motionTimeout && clearTimeout(this.motionTimeout); + this.motionTimeout = setTimeout(() => this.motionDetected = false, this.storageSettings.values.motionTimeout * 1000); + } else { + this.motionDetected = false; + this.motionTimeout && clearTimeout(this.motionTimeout); + } + } + + if (events.objects.length) { + const od: ObjectsDetected = { + timestamp: Date.now(), + detections: [], + }; + for (const c of events.objects) { + od.detections.push({ + className: c, + score: 1, + }); + } + sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od); + } + } + + async listenLoop() { + return null; + } + + async listenEvents() { + return null; + } + + async takeSnapshotInternal(timeout?: number) { + const now = Date.now(); + const client = this.getClient(); + const mo = await this.createMediaObject(await client.jpegSnapshot(this.getRtspChannel(), timeout), 'image/jpeg'); + this.lastB64Snapshot = await moToB64(mo); + this.lastSnapshotTaken = now; + + return mo; + } + + async takeSmartCameraPicture(options?: RequestPictureOptions): Promise { + const isBattery = this.hasBattery(); + const now = Date.now(); + const logger = this.getLogger(); + + const isMaxTimePassed = !this.lastSnapshotTaken || ((now - this.lastSnapshotTaken) > 1000 * 60 * 60); + const isBatteryTimePassed = !this.lastSnapshotTaken || ((now - this.lastSnapshotTaken) > 1000 * 15); + let canTake = false; + + if (!this.lastB64Snapshot || !this.lastSnapshotTaken) { + logger.log('Allowing new snapshot because not taken yet'); + canTake = true; + } else if (this.sleeping && isMaxTimePassed) { + logger.log('Allowing new snapshot while sleeping because older than 1 hour'); + canTake = true; + } else if (!this.sleeping && isBattery && isBatteryTimePassed) { + logger.log('Allowing new snapshot because older than 15 seconds'); + canTake = true; + } else { + canTake = true; + } + + if (canTake) { + return this.takeSnapshotInternal(options?.timeout); + } else if (this.lastB64Snapshot) { + const mo = await b64ToMo(this.lastB64Snapshot); + + return mo; + } else { + return null; + } + } + + getRtspChannel() { + return this.storageSettings.values.rtspChannel; + } + + createRtspMediaStreamOptions(url: string, index: number) { + const ret = createRtspMediaStreamOptions(url, index); + ret.tool = 'scrypted'; + return ret; + } + + addRtspCredentials(rtspUrl: string) { + const { username, password } = this.nvrDevice.storageSettings.values; + const url = new URL(rtspUrl); + // if (url.protocol !== 'rtmp:') { + url.username = username; + url.password = password; + // } else { + // const params = url.searchParams; + // for (const [k, v] of Object.entries(this.plugin.client.parameters)) { + // params.set(k, v); + // } + // } + return url.toString(); + } + + async createVideoStream(vso: UrlMediaStreamOptions): Promise { + await this.nvrDevice.client.login(); + return super.createVideoStream(vso); + } + + async getConstructedVideoStreamOptions(): Promise { + const encoderConfig: Enc = this.getEncoderSettings(); + + 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 abilities = this.getAbilities(); + const { channelInfo } = this.getDeviceData(); + + const live = abilities?.live?.ver; + const [rtmpMain, rtmpExt, rtmpSub, rtspMain, rtspSub] = streams; + streams.splice(0, streams.length); + + const mainEncType = abilities?.mainEncType?.ver; + + const isHomeHub = this.nvrDevice.info.model === 'Reolink Home Hub'; + if (isHomeHub) { + streams.push(...[rtspMain, rtspSub]); + } else 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); + } + + if (channelInfo.typeInfo && + [ + "Reolink TrackMix PoE", + "Reolink TrackMix WiFi", + "RLC-81MA", + "Trackmix Series W760" + ].includes(channelInfo.typeInfo)) { + 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; + } + } + } + } + + return streams; + } + + async getSettings(): Promise { + const settings = await this.storageSettings.getSettings(); + return settings; + } + + async putSetting(key: string, value: string) { + if (this.storageSettings.keys[key]) { + await this.storageSettings.putSetting(key, value); + } + else { + await super.putSetting(key, value); + } + } + + showRtspUrlOverride() { + return false; + } + + getRtspAddress() { + const { ipAddress, rtspPort } = this.nvrDevice.storageSettings.values; + return `${ipAddress}:${rtspPort}`; + } + + getRtmpAddress() { + const { ipAddress, rtmpPort } = this.nvrDevice.storageSettings.values; + return `${ipAddress}:${rtmpPort}`; + } + + async reportDevices() { + const hasSiren = this.hasSiren(); + const hasFloodlight = this.hasFloodlight(); + const hasPirEvents = this.hasPirEvents(); + + 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 (hasPirEvents) { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/plugins/reolink/src/nvr/nvr.ts b/plugins/reolink/src/nvr/nvr.ts new file mode 100644 index 000000000..cb513ff20 --- /dev/null +++ b/plugins/reolink/src/nvr/nvr.ts @@ -0,0 +1,381 @@ +import sdk, { Settings, ScryptedDeviceBase, Setting, SettingValue, DeviceDiscovery, AdoptDevice, DiscoveredDevice, Device, ScryptedInterface, ScryptedDeviceType, DeviceProvider, Reboot, DeviceCreatorSettings } from "@scrypted/sdk"; +import ReolinkProvider from "../main"; +import { StorageSettings } from "@scrypted/sdk/storage-settings"; +import { DevInfo } from "../probe"; +import { ReolinkNvrCamera } from "./camera"; +import { DeviceInputData, ReolinkNvrClient } from "./api"; + +export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, DeviceDiscovery, DeviceProvider, Reboot { + storageSettings = new StorageSettings(this, { + debugEvents: { + title: 'Debug Events', + type: 'boolean', + immediate: true, + }, + ipAddress: { + title: 'IP address', + type: 'string', + onPut: async () => await this.reinit() + }, + username: { + title: 'Username', + placeholder: 'admin', + defaultValue: 'admin', + type: 'string', + onPut: async () => await this.reinit() + }, + password: { + title: 'Password', + type: 'password', + onPut: async () => await this.reinit() + }, + httpPort: { + title: 'HTTP Port', + subgroup: 'Advanced', + defaultValue: 80, + placeholder: '80', + type: 'number', + onPut: async () => await this.reinit() + }, + rtspPort: { + subgroup: 'Advanced', + title: 'RTSP Port', + placeholder: '554', + defaultValue: 554, + type: 'number', + onPut: async () => await this.reinit() + }, + rtmpPort: { + subgroup: 'Advanced', + title: 'RTMP Port', + placeholder: '1935', + defaultValue: 1935, + type: 'number', + onPut: async () => await this.reinit() + }, + abilities: { + json: true, + hide: true, + defaultValue: {} + }, + devicesData: { + json: true, + hide: true, + defaultValue: {} + }, + hubData: { + json: true, + hide: true, + defaultValue: {} + }, + loginSession: { + json: true, + hide: true, + }, + }); + plugin: ReolinkProvider; + client: ReolinkNvrClient; + discoveredDevices = new Map(); + lastHubInfoCheck = undefined; + lastErrorsCheck = undefined; + lastDevicesStatusCheck = undefined; + cameraNativeMap = new Map(); + processing = false; + + constructor(nativeId: string, plugin: ReolinkProvider) { + super(nativeId); + this.plugin = plugin; + + setTimeout(async () => { + await this.init(); + }, 5000); + } + + async reboot(): Promise { + const client = this.getClient(); + await client.reboot(); + } + + getLogger() { + return this.console; + } + + async reinit() { + this.client = undefined; + // await this.init(); + } + + async init() { + const client = this.getClient(); + await client.login(); + const logger = this.getLogger(); + + setInterval(async () => { + if (this.processing || !client) { + return; + } + this.processing = true; + try { + const now = Date.now(); + + if (!this.lastErrorsCheck || (now - this.lastErrorsCheck > 60 * 1000)) { + this.lastErrorsCheck = now; + await client.checkErrors(); + } + + if (!this.lastHubInfoCheck || now - this.lastHubInfoCheck > 1000 * 60 * 5) { + logger.log('Starting Hub info data fetch'); + this.lastHubInfoCheck = now; + const { abilities, hubData, } = await client.getHubInfo(); + const { devicesData, channelsResponse, response } = await client.getDevicesInfo(); + logger.log('Hub info data fetched'); + if (this.storageSettings.values.debugEvents) { + logger.log(`${JSON.stringify({ abilities, hubData, devicesData, channelsResponse, response })}`); + } + this.storageSettings.values.abilities = abilities; + this.storageSettings.values.hubData = hubData; + this.storageSettings.values.devicesData = devicesData; + + await this.discoverDevices(true); + } + + const devicesMap = new Map(); + let anyBattery = false; + let anyAwaken = false; + + this.cameraNativeMap.forEach((camera) => { + if (camera) { + const channel = camera.storageSettings.values.rtspChannel; + + const abilities = camera.getAbilities(); + if (abilities) { + const hasBattery = camera.hasBattery(); + const hasPirEvents = camera.hasPirEvents(); + const hasFloodlight = camera.hasFloodlight(); + const sleeping = camera.sleeping; + const { hasPtz } = camera.getPtzCapabilities(); + devicesMap.set(Number(channel), { + hasFloodlight, + hasBattery, + hasPirEvents, + hasPtz, + sleeping + }); + + if (hasBattery && !anyBattery) { + anyBattery = true; + } + + if (!sleeping && !anyAwaken) { + anyAwaken = true; + } + } + } + }); + + const anyDeviceFound = devicesMap.size > 0; + + if (anyDeviceFound) { + const eventsRes = await client.getEvents(devicesMap); + + if (this.storageSettings.values.debugEvents) { + logger.debug(`Events call result: ${JSON.stringify(eventsRes)}`); + } + this.cameraNativeMap.forEach((camera) => { + if (camera) { + const channel = camera.storageSettings.values.rtspChannel; + const cameraEventsData = eventsRes?.parsed[channel]; + if (cameraEventsData) { + camera.processEvents(cameraEventsData); + } + } + }); + } + + if (anyBattery) { + const { batteryInfoData, response } = await client.getBatteryInfo(devicesMap); + + if (this.storageSettings.values.debugEvents) { + logger.debug(`Battery info call result: ${JSON.stringify({ batteryInfoData, response })}`); + } + + this.cameraNativeMap.forEach((camera) => { + if (camera) { + const channel = camera.storageSettings.values.rtspChannel; + const cameraBatteryData = batteryInfoData[channel]; + if (cameraBatteryData) { + camera.processBatteryData(cameraBatteryData); + } + } + }); + } + + if (anyDeviceFound) { + if (!this.lastDevicesStatusCheck || (now - this.lastDevicesStatusCheck > 15 * 1000) && anyAwaken) { + this.lastDevicesStatusCheck = now; + const { deviceStatusData, response } = await client.getStatusInfo(devicesMap); + + if (this.storageSettings.values.debugEvents) { + logger.info(`Status info raw result: ${JSON.stringify({ deviceStatusData, response })}`); + } + + this.cameraNativeMap.forEach((camera) => { + if (camera) { + const channel = camera.storageSettings.values.rtspChannel; + const cameraDeviceStatusData = deviceStatusData[channel]; + if (cameraDeviceStatusData) { + camera.processDeviceStatusData(cameraDeviceStatusData); + } + } + }); + } + } + } catch (e) { + this.console.error('Error on events flow', e); + } finally { + this.processing = false; + } + }, 1000); + } + + getClient() { + if (!this.client) { + const { ipAddress, httpPort, password, username } = this.storageSettings.values; + const address = `${ipAddress}:${httpPort}`; + this.client = new ReolinkNvrClient( + address, + username, + password, + this.console, + this, + ); + } + return this.client; + } + + updateDeviceInfo(devInfo: DevInfo) { + const info = this.info || {}; + info.ip = this.storageSettings.values.ipAddress; + info.serialNumber = devInfo.serial; + info.firmware = devInfo.firmVer; + info.version = devInfo.firmVer; + info.model = devInfo.model; + info.manufacturer = 'Reolink'; + info.managementUrl = `http://${info.ip}`; + this.info = info; + } + + async getSettings(): Promise { + const settings = await this.storageSettings.getSettings(); + + return settings; + } + + async putSetting(key: string, value: SettingValue): Promise { + return this.storageSettings.putSetting(key, value); + } + + + async releaseDevice(id: string, nativeId: string) { + this.cameraNativeMap.delete(nativeId); + } + + async getDevice(nativeId: string): Promise { + let device = this.cameraNativeMap.get(nativeId); + + if (!device) { + device = new ReolinkNvrCamera(nativeId, this); + this.cameraNativeMap.set(nativeId, device); + } + + return device; + } + + buildNativeId(uid: string) { + return `${this.nativeId}-${uid}`; + } + + getCameraInterfaces() { + return [ + ScryptedInterface.VideoCameraConfiguration, + ScryptedInterface.Camera, + ScryptedInterface.MotionSensor, + ScryptedInterface.VideoTextOverlays, + ScryptedInterface.MixinProvider, + ScryptedInterface.VideoCamera, + ScryptedInterface.Settings, + ]; + } + + async syncEntitiesFromRemote() { + const api = this.getClient(); + const { channels, devicesData } = await api.getDevicesInfo(); + + for (const channel of channels) { + const { channelStatus, channelInfo } = devicesData[channel]; + const name = channelStatus.name || `Channel ${channel}`; + + const nativeId = this.buildNativeId(channelStatus.uid); + const device: Device = { + nativeId, + name, + providerNativeId: this.nativeId, + interfaces: this.getCameraInterfaces() ?? [], + type: ScryptedDeviceType.Camera, + info: { + manufacturer: 'Reolink', + model: channelInfo.typeInfo + } + }; + + if (sdk.deviceManager.getNativeIds().includes(nativeId)) { + // const device = sdk.systemManager.getDeviceById(this.pluginId, nativeId); + // sdk.deviceManager.onDeviceDiscovered(device); + continue; + } + + if (this.discoveredDevices.has(nativeId)) { + continue; + } + + this.discoveredDevices.set(nativeId, { + device, + description: `${name}`, + rtspChannel: channel, + }); + } + } + + async discoverDevices(scan?: boolean): Promise { + if (scan) { + await this.syncEntitiesFromRemote(); + } + + return [...this.discoveredDevices.values()].map(d => ({ + ...d.device, + description: d.description, + })); + } + + async adoptDevice(adopt: AdoptDevice): Promise { + const entry = this.discoveredDevices.get(adopt.nativeId); + + if (!entry) + throw new Error('device not found'); + + await this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, await this.discoverDevices()); + + await sdk.deviceManager.onDeviceDiscovered(entry.device); + + const device = await this.getDevice(adopt.nativeId); + this.console.log('Adopted device', entry, device?.name); + device.storageSettings.values.rtspChannel = entry.rtspChannel; + + this.discoveredDevices.delete(adopt.nativeId); + return device?.id; + } +} \ No newline at end of file