Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions plugins/reolink/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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';
import { HTTPS_API_SETTING_DESCRIPTION, ReolinkNvrClient } from './nvr/api';

class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff {
sirenTimeout: NodeJS.Timeout;
Expand Down Expand Up @@ -1278,6 +1278,13 @@ class ReolinkProvider extends RtspProvider {
description: 'Optional: Override the HTTP Port from the default value of 80.',
placeholder: '80',
},
{
subgroup: 'Advanced',
key: 'https',
title: 'Use HTTPS',
description: `Only applies when "Is NVR" is enabled. ${HTTPS_API_SETTING_DESCRIPTION}`,
type: 'boolean',
},
{
subgroup: 'Advanced',
key: 'skipValidate',
Expand All @@ -1298,9 +1305,12 @@ class ReolinkProvider extends RtspProvider {
const ip = settings.ip?.toString();
const httpPort = settings.httpPort;
const rtspPort = settings.rtspPort;
const httpAddress = `${ip}:${httpPort || 80}`;
// Creator-form boolean settings arrive as strings ('true'/'false'), so a plain
// truthiness check would treat 'false' as enabled. Coerce like isNvr/skipValidate.
const https = settings.https?.toString() === 'true';
const httpAddress = `${ip}:${httpPort || (https ? 443 : 80)}`;

const client = new ReolinkNvrClient(httpAddress, username, password, this.console);
const client = new ReolinkNvrClient(httpAddress, username, password, this.console, undefined, https);
const { devInfo } = await client.getHubInfo();

if (!devInfo) {
Expand Down Expand Up @@ -1329,6 +1339,7 @@ class ReolinkProvider extends RtspProvider {
nvrDevice.storageSettings.values.password = password;
nvrDevice.storageSettings.values.httpPort = httpPort;
nvrDevice.storageSettings.values.rtspPort = rtspPort;
nvrDevice.storageSettings.values.https = https;

nvrDevice.updateDeviceInfo(devInfo);

Expand Down
64 changes: 38 additions & 26 deletions plugins/reolink/src/nvr/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import { AuthFetchCredentialState, authHttpFetch, HttpFetchOptions } from '@scry
import { PassThrough, Readable } from 'stream';
import { sleep } from "@scrypted/common/src/sleep";
import { PanTiltZoomCommand, VideoClipOptions } from "@scrypted/sdk";
import { DevInfo, getLoginParameters } from '../probe';
import { DevInfo, getLoginParameters, ReolinkProtocol } from '../probe';
import { ReolinkNvrDevice } from './nvr';

// Single source for the "Use HTTPS" setting copy so the creator-form (main.ts) and the
// device-settings (nvr.ts) descriptions can't drift. The creator form prepends an
// "Is NVR"-only caveat since that form is shared with the standalone camera flow.
export const HTTPS_API_SETTING_DESCRIPTION = 'Use HTTPS for the api.cgi control connection. Required for the Reolink Home Hub / Home Hub Pro, which only serve the API over HTTPS (typically port 443).';

type StoredLoginSession = {
host: string;
username: string;
Expand Down Expand Up @@ -119,23 +124,29 @@ export class ReolinkNvrClient {
connectionTime = Date.now();
console: Console;
host: string;
protocol: ReolinkProtocol;

maxSessionsCount = 0;
loginFirstCount = 0;

constructor(
httpAddress: string,
address: string,
username: string,
password: string,
console: Console,
public nvrDevice?: ReolinkNvrDevice
public nvrDevice?: ReolinkNvrDevice,
https = false,
) {
this.credential = {
username,
password,
};
this.host = httpAddress;
this.host = address;
this.console = console;
// Reolink Home Hub / Home Hub Pro serve the api.cgi endpoint over HTTPS only.
// Standalone cameras and most NVRs use plain HTTP. Default to HTTP for backwards
// compatibility; opt into HTTPS via the device's "Use HTTPS" setting.
this.protocol = https ? 'https' : 'http';
}

private async request(options: HttpFetchOptions<Readable>, body?: Readable) {
Expand Down Expand Up @@ -177,7 +188,7 @@ export class ReolinkNvrClient {
}

private async validateExistingSession(parameters: Record<string, string>) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'GetDevInfo');
for (const [k, v] of Object.entries(parameters)) {
Expand Down Expand Up @@ -258,7 +269,8 @@ export class ReolinkNvrClient {
this.host,
this.credential.username,
this.credential.password,
true
true,
this.protocol,
);

this.parameters = parameters;
Expand Down Expand Up @@ -328,7 +340,7 @@ export class ReolinkNvrClient {
}

async reboot() {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'Reboot');
this.rebooting = true;
Expand All @@ -351,7 +363,7 @@ export class ReolinkNvrClient {
}

async logout() {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [
{
Expand All @@ -376,7 +388,7 @@ export class ReolinkNvrClient {
}

async getOsd(channel: number): Promise<Osd> {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [
{
Expand All @@ -401,7 +413,7 @@ export class ReolinkNvrClient {
}

async setOsd(channel: number, osd: Osd) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [
{
Expand Down Expand Up @@ -436,7 +448,7 @@ export class ReolinkNvrClient {
}

async getHubInfo() {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const body = [
{
cmd: "GetAbility",
Expand Down Expand Up @@ -471,7 +483,7 @@ export class ReolinkNvrClient {
}

async jpegSnapshot(channel: number, timeout = 10000) {
const url = new URL(`http://${this.host}/cgi-bin/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/cgi-bin/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'Snap');
params.set('channel', String(channel));
Expand All @@ -486,7 +498,7 @@ export class ReolinkNvrClient {
}

async getEncoderConfiguration(channel: number): Promise<Enc> {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'GetEnc');
params.set('channel', String(channel));
Expand All @@ -499,7 +511,7 @@ export class ReolinkNvrClient {
}

private async ptzOp(channel: number, op: string, speed: number, id?: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'PtzCtrl');

Expand Down Expand Up @@ -540,7 +552,7 @@ export class ReolinkNvrClient {
}

private async presetOp(channel: number, speed: number, id: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'PtzCtrl');

Expand Down Expand Up @@ -596,7 +608,7 @@ export class ReolinkNvrClient {
}

async getSiren(channel: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [{
cmd: 'GetAudioAlarmV20',
Expand All @@ -621,7 +633,7 @@ export class ReolinkNvrClient {
}

async setSiren(channel: number, on: boolean, duration?: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'AudioAlarmPlay');

Expand Down Expand Up @@ -660,7 +672,7 @@ export class ReolinkNvrClient {
}

async setWhiteLedState(channel: number, on?: boolean, brightness?: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const settings: any = { channel };

Expand Down Expand Up @@ -690,7 +702,7 @@ export class ReolinkNvrClient {
}

async getStatusInfo(channelsMap: Map<number, DeviceInputData>) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const chanelIndex: Record<number, { osd?: number, floodlight?: number, pir?: number, presets?: number }> = {};

const body: any[] = [];
Expand Down Expand Up @@ -793,7 +805,7 @@ export class ReolinkNvrClient {
}

async getBatteryInfo(channelsMap: Map<number, DeviceInputData>) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);
const chanelIndex: Record<number, number> = {};

const body: any[] = [
Expand Down Expand Up @@ -848,7 +860,7 @@ export class ReolinkNvrClient {
}

async getChannels() {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const channelsBody = [{ cmd: 'GetChannelstatus' }];

Expand All @@ -869,7 +881,7 @@ export class ReolinkNvrClient {
}

async getEvents(channelsMap: Map<number, DeviceInputData>) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [];
const chanelIndex: Record<number, { events?: number, motion?: number, }> = {};
Expand Down Expand Up @@ -954,7 +966,7 @@ export class ReolinkNvrClient {
}

async getDevicesInfo() {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const { channels, channelsResponse } = await this.getChannels();

Expand Down Expand Up @@ -1017,7 +1029,7 @@ export class ReolinkNvrClient {
}

async getPirState(channel: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [{
cmd: 'GetPirInfo',
Expand All @@ -1043,7 +1055,7 @@ export class ReolinkNvrClient {
}

async setPirState(channel: number, on: boolean) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const currentPir = await this.getPirState(channel);
const newState = on ? 1 : 0;
Expand Down Expand Up @@ -1077,7 +1089,7 @@ export class ReolinkNvrClient {
}

async getLocalLink(channel: number) {
const url = new URL(`http://${this.host}/api.cgi`);
const url = new URL(`${this.protocol}://${this.host}/api.cgi`);

const body = [
{
Expand Down
41 changes: 32 additions & 9 deletions plugins/reolink/src/nvr/nvr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ReolinkProvider from "../main";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { DevInfo } from "../probe";
import { ReolinkNvrCamera } from "./camera";
import { DeviceInputData, ReolinkNvrClient } from "./api";
import { DeviceInputData, HTTPS_API_SETTING_DESCRIPTION, ReolinkNvrClient } from "./api";

export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, DeviceDiscovery, DeviceProvider, Reboot {
storageSettings = new StorageSettings(this, {
Expand All @@ -29,11 +29,30 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De
type: 'password',
onPut: async () => await this.reinit()
},
https: {
title: 'Use HTTPS',
subgroup: 'Advanced',
description: HTTPS_API_SETTING_DESCRIPTION,
type: 'boolean',
defaultValue: false,
onPut: async (ov, nv) => {
// A device created before the scheme-aware port default may have the old
// HTTP default (80) persisted; an explicit 443 likewise lingers when HTTPS
// is turned off. Clear a stored value that only matches the other scheme's
// default so the scheme-aware fallback in getClient() applies, keeping
// behavior consistent with the "Defaults to 80/443" copy. A genuinely
// custom port is preserved.
const port = this.storageSettings.values.httpPort;
if ((nv && port === 80) || (!nv && port === 443))
this.storageSettings.values.httpPort = undefined;
await this.reinit();
}
},
httpPort: {
title: 'HTTP Port',
title: 'API Port',
subgroup: 'Advanced',
defaultValue: 80,
placeholder: '80',
description: 'Optional override for the api.cgi port. Defaults to 80 for HTTP and 443 for HTTPS.',
placeholder: '80 / 443',
type: 'number',
onPut: async () => await this.reinit()
},
Expand Down Expand Up @@ -244,14 +263,18 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De

getClient() {
if (!this.client) {
const { ipAddress, httpPort, password, username } = this.storageSettings.values;
const address = `${ipAddress}:${httpPort}`;
const { ipAddress, httpPort, password, username, https } = this.storageSettings.values;
// Default the port to match the scheme when unset: 443 for HTTPS
// (Home Hub), 80 for HTTP. An explicit port override always wins.
const port = httpPort || (https ? 443 : 80);
const address = `${ipAddress}:${port}`;
this.client = new ReolinkNvrClient(
address,
username,
password,
address,
username,
password,
this.console,
this,
https,
);
}
return this.client;
Expand Down
Loading