diff --git a/server/src/shared/cloud-cameras/eagle-eye.ts b/server/src/shared/cloud-cameras/eagle-eye.ts new file mode 100644 index 0000000..1c6ed1a --- /dev/null +++ b/server/src/shared/cloud-cameras/eagle-eye.ts @@ -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): 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): Promise { + 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, vendorCameraId: string): Promise { + 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): Promise { + 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; + } + } +} diff --git a/server/src/shared/cloud-cameras/ezviz.ts b/server/src/shared/cloud-cameras/ezviz.ts new file mode 100644 index 0000000..8385596 --- /dev/null +++ b/server/src/shared/cloud-cameras/ezviz.ts @@ -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): 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): Promise { + 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, vendorCameraId: string): Promise { + 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): 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; + } + } +} diff --git a/server/src/shared/cloud-cameras/index.ts b/server/src/shared/cloud-cameras/index.ts index c0c8cd1..7ee46fc 100644 --- a/server/src/shared/cloud-cameras/index.ts +++ b/server/src/shared/cloud-cameras/index.ts @@ -6,13 +6,19 @@ export { type CloudAccount, type CloudCamera, type CloudVendor, type CloudCamera import { registerProvider } from "./types.js"; import { HikConnectProvider } from "./hikconnect.js"; +import { EzvizProvider } from "./ezviz.js"; import { DahuaProvider } from "./dahua.js"; import { TuyaProvider } from "./tuya.js"; import { UniviewProvider } from "./uniview.js"; import { TpLinkProvider } from "./tplink.js"; +import { ReolinkProvider } from "./reolink.js"; +import { EagleEyeProvider } from "./eagle-eye.js"; registerProvider(new HikConnectProvider()); +registerProvider(new EzvizProvider()); registerProvider(new DahuaProvider()); registerProvider(new TuyaProvider()); registerProvider(new UniviewProvider()); registerProvider(new TpLinkProvider()); +registerProvider(new ReolinkProvider()); +registerProvider(new EagleEyeProvider()); diff --git a/server/src/shared/cloud-cameras/reolink.ts b/server/src/shared/cloud-cameras/reolink.ts new file mode 100644 index 0000000..d97e0b2 --- /dev/null +++ b/server/src/shared/cloud-cameras/reolink.ts @@ -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): 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): Promise { + 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, _vendorCameraId: string): Promise { + 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 { + 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 ?? "" }; + } +} diff --git a/server/src/shared/cloud-cameras/types.ts b/server/src/shared/cloud-cameras/types.ts index 279f108..a08d4a0 100644 --- a/server/src/shared/cloud-cameras/types.ts +++ b/server/src/shared/cloud-cameras/types.ts @@ -18,18 +18,21 @@ export interface CloudAccount { 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[] = [ - "hikconnect", "dahua", "tuya", "uniview", "tplink", + "hikconnect", "ezviz", "dahua", "tuya", "uniview", "tplink", "reolink", "eagle_eye", ] as const; export const VENDOR_LABELS: Record = { - hikconnect: "Hik-Connect (Hikvision)", + hikconnect: "Hik-Connect for Teams", + ezviz: "EZVIZ (Hikvision Consumer)", dahua: "Dahua DMSS", tuya: "Tuya IoT", uniview: "Uniview Cloud", tplink: "TP-Link (Tapo/VIGI)", + reolink: "Reolink", + eagle_eye: "Eagle Eye Networks", }; export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null;