diff --git a/plugins/reolink/src/main.ts b/plugins/reolink/src/main.ts index ad50a19437..4430b6b23f 100644 --- a/plugins/reolink/src/main.ts +++ b/plugins/reolink/src/main.ts @@ -434,6 +434,23 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R return ret; } + addRtspCredentials(rtspUrl: string) { + const url = new URL(rtspUrl); + if (url.protocol !== 'rtmp:') { + url.username = this.storage.getItem('username'); + url.password = this.storage.getItem('password') || ''; + } else { + const params = url.searchParams; + params.set('token', this.client.token); + } + return url.toString(); + } + + async createVideoStream(vso: UrlMediaStreamOptions): Promise { + await this.client.login(); + return super.createVideoStream(vso); + } + async getConstructedVideoStreamOptions(): Promise { this.videoStreamOptions ||= this.getConstructedVideoStreamOptionsInternal().catch(e => { this.constructedVideoStreamOptions = undefined; @@ -521,8 +538,6 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R const params = streamUrl.searchParams; params.set("channel", this.getRtspChannel().toString()) params.set("stream", '0') - params.set("user", this.getUsername()) - params.set("password", this.getPassword()) stream.url = streamUrl.toString(); stream.name = `RTMP ${stream.id}`; } else if (stream.container === 'rtsp') { @@ -643,10 +658,6 @@ class ReolinkProvider extends RtspProvider { const skipValidate = settings.skipValidate?.toString() === 'true'; const username = settings.username?.toString(); const password = settings.password?.toString(); - // verify password only has alphanumeric characters because reolink can't handle - // url escaping. - if (!skipValidate && !/^[a-zA-Z0-9]+$/.test(password)) - throw new Error('Change the password this Reolink device to be alphanumeric characters only. See https://docs.scrypted.app/camera-preparation.html#authentication-setup for more information.'); let doorbell: boolean = false; let name: string = 'Reolink Camera'; let deviceInfo: DevInfo; diff --git a/plugins/reolink/src/reolink-api.ts b/plugins/reolink/src/reolink-api.ts index 5b8c4a54f7..85e70aa147 100644 --- a/plugins/reolink/src/reolink-api.ts +++ b/plugins/reolink/src/reolink-api.ts @@ -4,7 +4,6 @@ import https, { RequestOptions } from 'https'; import { PassThrough, Readable } from 'stream'; import { HttpFetchOptions, HttpFetchResponseType } from '../../../server/src/fetch/http-fetch'; -import { getMotionState, reolinkHttpsAgent } from './probe'; import { PanTiltZoomCommand } from "@scrypted/sdk"; import { sleep } from "@scrypted/common/src/sleep"; @@ -65,6 +64,8 @@ export type SirenResponse = { export class ReolinkCameraClient { credential: AuthFetchCredentialState; + token: string; + tokenLease: number = Date.now(); constructor(public host: string, public username: string, public password: string, public channelId: number, public console: Console) { this.credential = { @@ -73,11 +74,9 @@ export class ReolinkCameraClient { }; } - async request(urlOrOptions: string | URL | HttpFetchOptions, body?: Readable) { + private async request(options: HttpFetchOptions, body?: Readable) { const response = await authHttpFetch({ - ...typeof urlOrOptions !== 'string' && !(urlOrOptions instanceof URL) ? urlOrOptions : { - url: urlOrOptions, - }, + ...options, rejectUnauthorized: false, credential: this.credential, body, @@ -85,14 +84,61 @@ export class ReolinkCameraClient { return response; } + async login() { + if (this.tokenLease > Date.now()) { + return; + } + + this.console.log(`token expired at ${this.tokenLease}, renewing...`); + + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'Login'); + + const createReadable = (data: any) => { + const pt = new PassThrough(); + pt.write(Buffer.from(JSON.stringify(data))); + pt.end(); + return pt; + } + + const response = await this.request({ + url, + method: 'POST', + responseType: 'json', + }, createReadable([ + { + cmd: 'Login', + action: 0, + param: { + User: { + userName: this.username, + password: this.password + } + } + }, + ])); + this.token = response.body?.[0]?.value?.Token?.name || response.body?.value?.Token?.name; + if (!this.token) { + throw new Error('unable to login'); + } + this.tokenLease = Date.now() + 1000 * (response.body?.[0]?.value?.Token.leaseTime || response.body?.value?.Token.leaseTime); + } + + async requestWithLogin(options: HttpFetchOptions, body?: Readable) { + await this.login(); + const url = options.url as URL; + const params = url.searchParams; + params.set('token', this.token); + return this.request(options, body); + } + async reboot() { const url = new URL(`http://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'Reboot'); - params.set('user', this.username); - params.set('password', this.password); - const response = await this.request({ - url: url.toString(), + const response = await this.requestWithLogin({ + url, responseType: 'json', }); return { @@ -111,7 +157,18 @@ export class ReolinkCameraClient { // } // ] async getMotionState() { - return getMotionState(this.credential, this.username, this.password, this.host, this.channelId); + const url = new URL(`http://${this.host}/api.cgi`); + const params = url.searchParams; + params.set('cmd', 'GetMdState'); + params.set('channel', this.channelId.toString()); + const response = await this.requestWithLogin({ + url, + responseType: 'json', + }); + return { + value: !!response.body?.[0]?.value?.state, + data: response.body, + }; } async getAiState() { @@ -119,9 +176,7 @@ export class ReolinkCameraClient { const params = url.searchParams; params.set('cmd', 'GetAiState'); params.set('channel', this.channelId.toString()); - params.set('user', this.username); - params.set('password', this.password); - const response = await this.request({ + const response = await this.requestWithLogin({ url, responseType: 'json', }); @@ -136,9 +191,7 @@ export class ReolinkCameraClient { 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({ + const response = await this.requestWithLogin({ url, responseType: 'json', }); @@ -154,10 +207,8 @@ export class ReolinkCameraClient { params.set('cmd', 'Snap'); params.set('channel', this.channelId.toString()); params.set('rs', Date.now().toString()); - params.set('user', this.username); - params.set('password', this.password); - const response = await this.request({ + const response = await this.requestWithLogin({ url, timeout, }); @@ -171,9 +222,7 @@ export class ReolinkCameraClient { params.set('cmd', 'GetEnc'); // is channel used on this call? params.set('channel', this.channelId.toString()); - params.set('user', this.username); - params.set('password', this.password); - const response = await this.request({ + const response = await this.requestWithLogin({ url, responseType: 'json', }); @@ -185,9 +234,7 @@ export class ReolinkCameraClient { const url = new URL(`http://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'GetDevInfo'); - params.set('user', this.username); - params.set('password', this.password); - const response = await this.request({ + const response = await this.requestWithLogin({ url, responseType: 'json', }); @@ -195,12 +242,10 @@ export class ReolinkCameraClient { return response.body?.[0]?.value?.DevInfo; } - async ptzOp(op: string) { + private async ptzOp(op: string) { const url = new URL(`http://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'PtzCtrl'); - params.set('user', this.username); - params.set('password', this.password); const createReadable = (data: any) => { const pt = new PassThrough(); @@ -209,7 +254,7 @@ export class ReolinkCameraClient { return pt; } - const c1 = this.request({ + const c1 = this.requestWithLogin({ url, method: 'POST', responseType: 'text', @@ -227,7 +272,7 @@ export class ReolinkCameraClient { await sleep(500); - const c2 = this.request({ + const c2 = this.requestWithLogin({ url, method: 'POST', }, createReadable([ @@ -273,8 +318,6 @@ export class ReolinkCameraClient { 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))); @@ -296,7 +339,7 @@ export class ReolinkCameraClient { }; } - const response = await this.request({ + const response = await this.requestWithLogin({ url, method: 'POST', responseType: 'json',