UNPKG

@apocaliss92/scrypted-reolink-hub

Version:
1,126 lines (949 loc) 33.9 kB
import { AuthFetchCredentialState, authHttpFetch } 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 { VideoSearchResult, VideoSearchType } from '../../scrypted-reolink-videoclips/src/client'; import { getLoginParameters } from '../../scrypted/plugins/reolink/src/probe'; import { HttpFetchOptions } from '../../scrypted/server/src/fetch/http-fetch'; const wakingCoomands = [ "GetWhiteLed", "GetZoomFocus", "GetAudioCfg", "GetPtzGuard", "GetAutoReply", "GetPtzTraceSection", "GetAiCfg", "GetAiAlarm", "GetPtzCurPos", "GetAudioAlarm", "GetDingDongList", "GetDingDongCfg", "DingDongOpt", "GetPerformance", "296", "483", ]; 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?: string, ai?: any, channelInfo?: 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<Record<AiKey, AIDetectionState>> & { channel: number; }; export type SirenResponse = { rspCode: number; } export interface PtzPreset { id: number; name: string; } export class ReolinkHubClient { credential: AuthFetchCredentialState; parameters: Record<string, string>; tokenLease: number; loggingIn = false; loggedIn = false; rebooting = false; conmnectionTime = Date.now(); maxSessionsCount = 0; loginFirstCount = 0; constructor(public host: string, public username: string, public password: string, public console: Console) { this.credential = { username, password, }; } private async request(options: HttpFetchOptions<Readable>, 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; } async login() { if (this.parameters && this.tokenLease && this.tokenLease > Date.now()) { return; } if (this.loggingIn) { return; } this.loggingIn = true; 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.username, this.password, true); this.parameters = parameters; const now = Date.now(); this.tokenLease = now + 1000 * leaseTimeSeconds; this.loggingIn = false; this.loggedIn = true; this.conmnectionTime = now; } async checkErrors() { if (this.rebooting) { return; } if (Date.now() - this.conmnectionTime > 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<Readable>, 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 = {}; } async reconnect() { await this.logout(); await this.login(); } async getOsd(channel: number): Promise<Osd> { 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); } } async getAiState(channel: number) { const url = new URL(`http://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'GetAiState'); params.set('channel', String(channel)); const response = await this.requestWithLogin({ url, responseType: 'json', }); return { value: (response?.body?.[0]?.value || response?.body?.value) as AIState, data: response?.body, }; } 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.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; return { abilities, hubData, 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<Enc> { 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<number, DeviceInputData>) { const url = new URL(`http://${this.host}/api.cgi`); const chanelIndex: Record<number, { osd?: number, floodlight?: number, pir?: number, presets?: number }> = {}; 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<number, DeviceStatusResponse> = {}; 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<number, DeviceInputData>) { const url = new URL(`http://${this.host}/api.cgi`); const chanelIndex: Record<number, number> = {}; 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<number, BatteryInfoResponse> = {}; 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<number, DeviceInputData>) { const url = new URL(`http://${this.host}/api.cgi`); const body = []; const chanelIndex: Record<number, { events?: number, motion?: number, }> = {}; 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<number, EventsResponse> = {}; 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<number, { chnInfo: number, ai: number }> = {}; for (const channel of channels) { responseMap[channel] = { ai: body.length, chnInfo: body.length + 1, } body.push( { cmd: "GetChnTypeInfo", action: 0, param: { channel } }, { cmd: "GetAiState", action: 0, param: { channel } }, ); } const response = await this.requestWithLogin({ url, responseType: 'json', method: 'POST', }, this.createReadable(body)); const ret: Record<number, DeviceInfoResponse> = {}; let currentChannelIndex = 0; for (const channel of channels) { const indexMultilpier = currentChannelIndex * 2; const chnInfoItem = response.body[indexMultilpier]; const aiItem = response.body[indexMultilpier + 1]; const channelStatus = channelsResponse.body?.[0]?.value?.status?.find(item => item?.channel === channel); ret[channel] = { entries: [chnInfoItem, aiItem], }; !chnInfoItem?.error && (ret[channel].channelInfo = chnInfoItem?.value); !aiItem?.error && (ret[channel].ai = aiItem?.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 }; } async getVideoClips( channel: number, options?: VideoClipOptions, streamType: VideoSearchType = 'main', ) { const url = new URL(`http://${this.host}/api.cgi`); const startTime = new Date(options.startTime); let endTime = options.endTime ? new Date(options.endTime) : undefined; // If the endTime is not the same day as startTime, // or no endDate is provided, set to the end of the startTime // Reolink only supports 1 day recordings fetching if (!endTime || endTime.getDate() > startTime.getDate()) { endTime = new Date(startTime); endTime.setHours(23); endTime.setMinutes(59); endTime.setSeconds(59); } const body = [ { cmd: "Search", action: 1, param: { Search: { channel, streamType, onlyStatus: 0, StartTime: { year: startTime.getFullYear(), mon: startTime.getMonth() + 1, day: startTime.getDate(), hour: startTime.getHours(), min: startTime.getMinutes(), sec: startTime.getSeconds() }, EndTime: { year: endTime.getFullYear(), mon: endTime.getMonth() + 1, day: endTime.getDate(), hour: endTime.getHours(), min: endTime.getMinutes(), sec: endTime.getSeconds() } } } } ]; try { const response = await this.requestWithLogin({ url, responseType: 'json', method: 'POST', }, this.createReadable(body)); const error = response?.body?.[0]?.error; if (error) { this.console.log('Error fetching videoclips', error, JSON.stringify({ body, url })); return []; } return (response?.body?.[0]?.value?.SearchResult?.File ?? []) as VideoSearchResult[]; } catch (e) { this.console.log('Error fetching videoclips', e); return []; } } async getVideoClipUrl(videoclipPath: string, channel: number) { const fileNameWithExtension = videoclipPath.split('/').pop(); // const fileName = fileNameWithExtension.split('.').shift(); let sanitizedPath = videoclipPath.replaceAll(' ', '%20'); if (!sanitizedPath.startsWith('/')) { sanitizedPath = `/${sanitizedPath}`; } const match = fileNameWithExtension.match(/.*Rec(\w{3})(?:_|_DST)(\d{8})_(\d{6})_.*/); const date = match[2]; const time = match[3]; const start = `${date}${time}`; const playbackPath = `cgi-bin/api.cgi?cmd=Playback&channel=${channel}&source=${sanitizedPath}&start=${start}&type=0&seek=0&token=${this.parameters.token}`; const downloadPath = `cgi-bin/api.cgi?cmd=Download&source=${sanitizedPath}&output=ha_playback_${start}.mp4&start=${start}&token=${this.parameters.token}`; return { // playbackPathWithHost: `http://${this.host}/${playbackPath}`, downloadPathWithHost: `http://${this.host}/${downloadPath}`, }; } }