feat(cloud-cameras): add EZVIZ, Reolink, Eagle Eye providers

EZVIZ: consumer Hikvision cameras via Open Platform API (appKey/appSecret).
Reolink: local HTTP API + RTSP (no cloud API available).
Eagle Eye Networks: OAuth2 cloud VMS with HLS relay URLs.
This commit is contained in:
Mitchell R 2026-05-24 02:54:49 +02:00
parent 0479cb7b4b
commit 0bb8fb68c9
No known key found for this signature in database
5 changed files with 407 additions and 3 deletions

View file

@ -0,0 +1,125 @@
/**
* Eagle Eye Networks cloud camera integration.
*
* Auth: OAuth2 client_credentials access_token.
* API base: https://api.eagleeyenetworks.com/api/v3.0
* Camera list: GET /cameras (paginated).
* Live view: GET /cameras/{id}/streams RTSP/HLS URL.
*
* Eagle Eye is a true cloud VMS cameras stream to their cloud,
* we pull relay URLs. All auth on server.
*/
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
const API_BASE = "https://api.eagleeyenetworks.com/api/v3.0";
const AUTH_URL = "https://auth.eagleeyenetworks.com/oauth2/token";
export class EagleEyeProvider implements CloudCameraProvider {
vendor: CloudVendor = "eagle_eye";
credentialFields() {
return [
{ name: "client_id", label: "Client ID", type: "text" as const, required: true },
{ name: "client_secret", label: "Client Secret", type: "password" as const, required: true },
{ name: "api_key", label: "API Key", type: "text" as const, required: true },
];
}
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
try {
const token = await this.getToken(creds);
return token ? { ok: true } : { ok: false, error: "Auth failed" };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
const token = await this.getToken(creds);
if (!token) return [];
const cameras: CloudCamera[] = [];
let nextPageToken: string | null = null;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (nextPageToken) params.set("pageToken", nextPageToken);
const resp = await fetch(`${API_BASE}/cameras?${params}`, {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/json",
},
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) break;
const data = await resp.json() as any;
for (const cam of data.results ?? []) {
cameras.push({
vendor_id: cam.id,
name: cam.name ?? `Eagle Eye ${cam.id}`,
model: cam.settings?.camera_info?.model ?? null,
rtsp_url: null,
relay_url: null,
online: cam.status === "online",
stream_type: "hls",
extra: {
account_id: cam.accountId ?? null,
timezone: cam.timezone ?? null,
tags: cam.tags ?? [],
},
});
}
nextPageToken = data.nextPageToken ?? null;
} while (nextPageToken);
return cameras;
}
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
const token = await this.getToken(creds);
if (!token) return null;
try {
const resp = await fetch(`${API_BASE}/cameras/${vendorCameraId}/streams`, {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/json",
},
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
return data.hlsUrl ?? data.rtspUrl ?? null;
} catch {
return null;
}
}
private async getToken(creds: Record<string, string>): Promise<string | null> {
const clientId = creds["client_id"];
const clientSecret = creds["client_secret"];
if (!clientId || !clientSecret) return null;
try {
const resp = await fetch(AUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
return data.access_token ?? null;
} catch {
return null;
}
}
}

View file

@ -0,0 +1,144 @@
/**
* EZVIZ Open Platform (consumer Hikvision cameras).
*
* Auth: appKey + appSecret accessToken + areaDomain.
* Camera list: POST {areaDomain}/api/lapp/camera/list (paginated).
* Live view: POST {areaDomain}/api/lapp/live/video/list HLS URLs.
*
* Initial auth endpoint: https://open.ezvizlife.com/api/lapp/token/get
* Subsequent calls use areaDomain from token response.
*
* Notes:
* - Streams encrypted by default (device verification code = key)
* - Supports HLS, RTMP, FLV
* - OAuth-based accounts (Google/FB) NOT supported email/password EZVIZ only
* - Two-step verification must be disabled
*/
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
const AUTH_BASE = "https://open.ezvizlife.com";
export class EzvizProvider implements CloudCameraProvider {
vendor: CloudVendor = "ezviz";
credentialFields() {
return [
{ name: "app_key", label: "App Key", type: "text" as const, required: true },
{ name: "app_secret", label: "App Secret", type: "password" as const, required: true },
];
}
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
try {
const auth = await this.getToken(creds);
return auth ? { ok: true } : { ok: false, error: "Token request failed" };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
const auth = await this.getToken(creds);
if (!auth) return [];
const cameras: CloudCamera[] = [];
let pageStart = 0;
const pageSize = 50;
while (true) {
const resp = await fetch(`${auth.areaDomain}/api/lapp/camera/list`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
accessToken: auth.accessToken,
pageStart: String(pageStart),
pageSize: String(pageSize),
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) break;
const data = await resp.json() as any;
if (data.code !== "200" && data.code !== 200) break;
const list = data.data ?? [];
if (list.length === 0) break;
for (const cam of list) {
cameras.push({
vendor_id: `${cam.deviceSerial}:${cam.channelNo ?? 1}`,
name: cam.deviceName ?? cam.channelName ?? `EZVIZ ${cam.deviceSerial}`,
model: cam.deviceType ?? null,
rtsp_url: null,
relay_url: null,
online: cam.status === 1 || cam.status === "1",
stream_type: "hls",
extra: {
device_serial: cam.deviceSerial,
channel_no: cam.channelNo ?? 1,
is_encrypt: cam.isEncrypt ?? 0,
pic_url: cam.picUrl ?? null,
},
});
}
if (list.length < pageSize) break;
pageStart += pageSize;
}
return cameras;
}
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
const auth = await this.getToken(creds);
if (!auth) return null;
const [deviceSerial, channelNo] = vendorCameraId.split(":");
const resp = await fetch(`${auth.areaDomain}/api/lapp/v2/live/address/get`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
accessToken: auth.accessToken,
deviceSerial: deviceSerial!,
channelNo: channelNo ?? "1",
protocol: "2", // 1=ezopen, 2=HLS, 3=RTMP, 4=FLV
quality: "1", // 1=HD, 2=SD
expireTime: "86400",
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
if ((data.code !== "200" && data.code !== 200) || !data.data?.url) return null;
return data.data.url;
}
private async getToken(creds: Record<string, string>): Promise<{
accessToken: string;
areaDomain: string;
} | null> {
const appKey = creds["app_key"];
const appSecret = creds["app_secret"];
if (!appKey || !appSecret) return null;
try {
const resp = await fetch(`${AUTH_BASE}/api/lapp/token/get`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ appKey, appSecret }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
if ((data.code !== "200" && data.code !== 200) || !data.data?.accessToken) return null;
return {
accessToken: data.data.accessToken,
areaDomain: data.data.areaDomain ?? AUTH_BASE,
};
} catch {
return null;
}
}
}

View file

@ -6,13 +6,19 @@ export { type CloudAccount, type CloudCamera, type CloudVendor, type CloudCamera
import { registerProvider } from "./types.js"; import { registerProvider } from "./types.js";
import { HikConnectProvider } from "./hikconnect.js"; import { HikConnectProvider } from "./hikconnect.js";
import { EzvizProvider } from "./ezviz.js";
import { DahuaProvider } from "./dahua.js"; import { DahuaProvider } from "./dahua.js";
import { TuyaProvider } from "./tuya.js"; import { TuyaProvider } from "./tuya.js";
import { UniviewProvider } from "./uniview.js"; import { UniviewProvider } from "./uniview.js";
import { TpLinkProvider } from "./tplink.js"; import { TpLinkProvider } from "./tplink.js";
import { ReolinkProvider } from "./reolink.js";
import { EagleEyeProvider } from "./eagle-eye.js";
registerProvider(new HikConnectProvider()); registerProvider(new HikConnectProvider());
registerProvider(new EzvizProvider());
registerProvider(new DahuaProvider()); registerProvider(new DahuaProvider());
registerProvider(new TuyaProvider()); registerProvider(new TuyaProvider());
registerProvider(new UniviewProvider()); registerProvider(new UniviewProvider());
registerProvider(new TpLinkProvider()); registerProvider(new TpLinkProvider());
registerProvider(new ReolinkProvider());
registerProvider(new EagleEyeProvider());

View file

@ -0,0 +1,126 @@
/**
* Reolink camera integration.
*
* Reolink cameras support local HTTP API + RTSP. No public cloud API
* for third-party streaming. Cameras expose:
* - RTSP: rtsp://admin:pass@ip/h264Preview_01_main (main)
* rtsp://admin:pass@ip/h264Preview_01_sub (sub)
* - HTTP API on port 80/443 for device info, PTZ, etc.
*
* This provider uses the local HTTP API for discovery + RTSP for streams.
*/
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
export class ReolinkProvider implements CloudCameraProvider {
vendor: CloudVendor = "reolink";
credentialFields() {
return [
{ name: "host", label: "Camera IP/Host", type: "text" as const, required: true },
{ name: "username", label: "Username", type: "text" as const, required: true },
{ name: "password", label: "Password", type: "password" as const, required: true },
];
}
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
const host = creds["host"];
const username = creds["username"];
const password = creds["password"];
if (!host || !username || !password) return { ok: false, error: "Missing credentials" };
try {
const resp = await fetch(
`http://${host}/api.cgi?cmd=Login`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([{
cmd: "Login",
param: { User: { userName: username, password } },
}]),
signal: AbortSignal.timeout(5000),
},
);
if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as any;
const result = data?.[0];
if (result?.code === 0 || result?.value?.Token) return { ok: true };
return { ok: false, error: result?.error?.detail ?? "Login failed" };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
const host = creds["host"];
const username = creds["username"];
const password = creds["password"];
if (!host || !username || !password) return [];
let name = `Reolink (${host})`;
let model: string | null = null;
try {
const token = await this.login(host, username, password);
if (token) {
const info = await this.getDevInfo(host, token);
if (info) {
name = info.name || name;
model = info.model;
}
}
} catch { /* fallback to defaults */ }
const mainUrl = `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_main`;
return [{
vendor_id: host,
name,
model,
rtsp_url: mainUrl,
relay_url: null,
online: true,
stream_type: "rtsp",
extra: {
sub_url: `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_sub`,
},
}];
}
async getStreamUrl(creds: Record<string, string>, _vendorCameraId: string): Promise<string | null> {
const host = creds["host"];
const username = creds["username"];
const password = creds["password"];
if (!host || !username || !password) return null;
return `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_main`;
}
private async login(host: string, username: string, password: string): Promise<string | null> {
const resp = await fetch(`http://${host}/api.cgi?cmd=Login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([{
cmd: "Login",
param: { User: { userName: username, password } },
}]),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
return data?.[0]?.value?.Token?.name ?? null;
}
private async getDevInfo(host: string, token: string): Promise<{ name: string; model: string } | null> {
const resp = await fetch(`http://${host}/api.cgi?cmd=GetDevInfo&token=${token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([{ cmd: "GetDevInfo", action: 0, param: {} }]),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const data = await resp.json() as any;
const info = data?.[0]?.value?.DevInfo;
if (!info) return null;
return { name: info.name ?? "", model: info.model ?? "" };
}
}

View file

@ -18,18 +18,21 @@ export interface CloudAccount {
created_at: string; created_at: string;
} }
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink"; export type CloudVendor = "hikconnect" | "ezviz" | "dahua" | "tuya" | "uniview" | "tplink" | "reolink" | "eagle_eye";
export const CLOUD_VENDORS: readonly CloudVendor[] = [ export const CLOUD_VENDORS: readonly CloudVendor[] = [
"hikconnect", "dahua", "tuya", "uniview", "tplink", "hikconnect", "ezviz", "dahua", "tuya", "uniview", "tplink", "reolink", "eagle_eye",
] as const; ] as const;
export const VENDOR_LABELS: Record<CloudVendor, string> = { export const VENDOR_LABELS: Record<CloudVendor, string> = {
hikconnect: "Hik-Connect (Hikvision)", hikconnect: "Hik-Connect for Teams",
ezviz: "EZVIZ (Hikvision Consumer)",
dahua: "Dahua DMSS", dahua: "Dahua DMSS",
tuya: "Tuya IoT", tuya: "Tuya IoT",
uniview: "Uniview Cloud", uniview: "Uniview Cloud",
tplink: "TP-Link (Tapo/VIGI)", tplink: "TP-Link (Tapo/VIGI)",
reolink: "Reolink",
eagle_eye: "Eagle Eye Networks",
}; };
export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null; export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null;