From 1421feb7b4bc207458a034ad4ea4f23154fe4323 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 12:16:42 +0200 Subject: [PATCH] fix(hikconnect): rewrite to HikCentral Connect OpenAPI v2.15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was using consumer api.hik-connect.com (wrong API). Rewritten to use HikCentral Connect enterprise API per vendor docs: - Auth: POST /api/hccgw/platform/v1/token/get with appKey + secretKey - Cameras: POST /api/hccgw/resource/v1/areas/cameras/get (paginated) - Live view: POST /api/hccgw/video/v1/live/address/get → RTMP URL - Credential fields: app_key (AK), secret_key (SK), region - Region-specific server addresses (eu/us/sg/sa/ru) - Token response returns areaDomain for subsequent calls - RTMP protocol=3, quality=1 (HD), expireTime=86400 (24h) Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/shared/cloud-cameras/hikconnect.ts | 195 ++++++++++++------ 1 file changed, 134 insertions(+), 61 deletions(-) diff --git a/server/src/shared/cloud-cameras/hikconnect.ts b/server/src/shared/cloud-cameras/hikconnect.ts index 70ba73f..0991cd9 100644 --- a/server/src/shared/cloud-cameras/hikconnect.ts +++ b/server/src/shared/cloud-cameras/hikconnect.ts @@ -1,122 +1,195 @@ /** - * Hik-Connect (Hikvision cloud) integration. + * Hik-Connect for Teams (HikCentral Connect) OpenAPI integration. * - * Hikvision cloud API at api.hik-connect.com. Auth via username/password - * → access token. Device list returns serials, names, online status. - * Streaming: request HLS preview URL via /v3/open/devices/:serial/previewURLs. - * URLs are session-based and expire — kiosk must refresh via server API. + * Auth: appKey (AK) + secretKey (SK) → accessToken + areaDomain. + * Camera list: POST /api/hccgw/resource/v1/areas/cameras/get (paginated). + * Live view: POST /api/hccgw/video/v1/live/address/get → RTMP URL. * - * All auth on server — kiosk only gets HLS URLs in the bundle. + * Server addresses per region: + * Russia: https://hikcentralconnectru.com + * Singapore: https://isgp.hikcentralconnect.com + * Europe: https://ieu.hikcentralconnect.com + * South America: https://isa.hikcentralconnect.com + * North America: https://ius.hikcentralconnect.com + * + * Notes from docs: + * - India/Russia do NOT support RTMP/HLS + * - RTMP/HLS: H.264 only, no playback, no stream encryption + * - RTMP expireTime: 30s–720d + * - All auth on server — kiosk only gets streaming URLs */ import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js"; -const API_BASE = "https://api.hik-connect.com"; +const REGION_BASES: Record = { + ru: "https://hikcentralconnectru.com", + sg: "https://isgp.hikcentralconnect.com", + eu: "https://ieu.hikcentralconnect.com", + sa: "https://isa.hikcentralconnect.com", + us: "https://ius.hikcentralconnect.com", +}; export class HikConnectProvider implements CloudCameraProvider { vendor: CloudVendor = "hikconnect"; credentialFields() { return [ - { name: "username", label: "Hik-Connect Email/Phone", type: "email" as const, required: true }, - { name: "password", label: "Password", type: "password" as const, required: true }, - { name: "region", label: "Region (eu/us/ap)", type: "text" as const, required: false }, + { name: "app_key", label: "App Key (AK)", type: "text" as const, required: true }, + { name: "secret_key", label: "Secret Key (SK)", type: "password" as const, required: true }, + { name: "region", label: "Region (eu/us/sg/sa/ru)", type: "text" as const, required: false }, ]; } async testCredentials(creds: Record): Promise<{ ok: boolean; error?: string }> { try { - const token = await this.login(creds); - return token ? { ok: true } : { ok: false, error: "Login failed" }; + 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 token = await this.login(creds); - if (!token) return []; + const auth = await this.getToken(creds); + if (!auth) return []; - try { - const resp = await fetch(`${this.apiBase(creds)}/v3/userdevices/v1/devices/list`, { + const cameras: CloudCamera[] = []; + let pageIndex = 1; + const pageSize = 200; + + while (true) { + const resp = await fetch(`${auth.areaDomain}/api/hccgw/resource/v1/areas/cameras/get`, { + method: "POST", headers: { - "Authorization": `Bearer ${token}`, "Content-Type": "application/json", + "Token": auth.accessToken, }, + body: JSON.stringify({ + pageIndex: String(pageIndex), + pageSize: String(pageSize), + filter: { areaID: "-1", includeSubArea: "1" }, + }), + signal: AbortSignal.timeout(15000), }); - if (!resp.ok) return []; - const data = await resp.json() as any; - const devices = data?.data?.list ?? data?.deviceList ?? []; - const cameras: CloudCamera[] = []; - for (const d of devices) { - const serial = d.deviceSerial ?? d.serial ?? String(d.id); - const streamUrl = await this.fetchPreviewUrl(this.apiBase(creds), token, serial); + if (!resp.ok) break; + const data = await resp.json() as any; + if (data.errorCode !== "0") break; + + const cameraList = data.data?.camera ?? []; + for (const cam of cameraList) { + const serial = cam.device?.devInfo?.serialNo ?? ""; + const channelNo = cam.device?.channelInfo?.no ?? "1"; + + let streamUrl: string | null = null; + let streamType: "rtmp" | "hls" | null = null; + if (cam.online === "1") { + const live = await this.fetchLiveAddress( + auth.areaDomain, auth.accessToken, cam.id, serial, + ); + if (live) { + streamUrl = live.url; + streamType = "rtmp"; + } + } + cameras.push({ - vendor_id: serial, - name: d.deviceName ?? d.name ?? "Hikvision Camera", - model: d.deviceModel ?? d.model ?? null, + vendor_id: cam.id, + name: cam.name ?? `Hik Ch${channelNo}`, + model: null, rtsp_url: null, relay_url: streamUrl, - online: d.status === "online" || d.online === true, - stream_type: streamUrl ? "hls" : null, - extra: { serial, type: d.deviceType, local_ip: d.localIp ?? d.ip ?? null }, + online: cam.online === "1", + stream_type: streamType, + extra: { + serial, + channel_no: channelNo, + ability_set: cam.abilitySet ?? "", + area_name: cam.area?.name ?? null, + }, }); } - return cameras; - } catch { - return []; + + const total = data.data?.totalCount ?? 0; + if (pageIndex * pageSize >= total) break; + pageIndex++; } + + return cameras; } async getStreamUrl(creds: Record, vendorCameraId: string): Promise { - const token = await this.login(creds); - if (!token) return null; - return this.fetchPreviewUrl(this.apiBase(creds), token, vendorCameraId); + const auth = await this.getToken(creds); + if (!auth) return null; + + const camsResp = await fetch(`${auth.areaDomain}/api/hccgw/resource/v1/areas/cameras/get`, { + method: "POST", + headers: { "Content-Type": "application/json", "Token": auth.accessToken }, + body: JSON.stringify({ + pageIndex: "1", pageSize: "1", + filter: { cameraID: [vendorCameraId] }, + }), + signal: AbortSignal.timeout(10000), + }); + if (!camsResp.ok) return null; + const camsData = await camsResp.json() as any; + const cam = camsData.data?.camera?.[0]; + if (!cam) return null; + + const serial = cam.device?.devInfo?.serialNo ?? ""; + const live = await this.fetchLiveAddress(auth.areaDomain, auth.accessToken, vendorCameraId, serial); + return live?.url ?? null; } - private async fetchPreviewUrl(base: string, token: string, serial: string): Promise { + private async fetchLiveAddress( + areaDomain: string, token: string, resourceId: string, deviceSerial: string, + ): Promise<{ url: string; expireTime: number } | null> { try { - const resp = await fetch(`${base}/v3/open/devices/${serial}/previewURLs`, { + const resp = await fetch(`${areaDomain}/api/hccgw/video/v1/live/address/get`, { method: "POST", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ protocol: "hls", quality: 1 }), - signal: AbortSignal.timeout(10000), + headers: { "Content-Type": "application/json", "Token": token }, + body: JSON.stringify({ + resourceId, + deviceSerial, + type: "1", + protocol: 3, + quality: "1", + expireTime: 86400, + }), + signal: AbortSignal.timeout(15000), }); if (!resp.ok) return null; const data = await resp.json() as any; - return data?.data?.url ?? data?.url ?? null; + if (data.errorCode !== "0" || !data.data?.url) return null; + return { url: data.data.url, expireTime: data.data.expireTime ?? 0 }; } catch { return null; } } - private apiBase(creds: Record): string { - const region = (creds["region"] ?? "eu").toLowerCase(); - if (region === "us") return "https://api.hik-connect.com"; - if (region === "ap") return "https://api.hik-connect.com"; - return API_BASE; - } + private async getToken(creds: Record): Promise<{ + accessToken: string; + areaDomain: string; + } | null> { + const appKey = creds["app_key"]; + const secretKey = creds["secret_key"]; + if (!appKey || !secretKey) return null; - private async login(creds: Record): Promise { - const { username, password } = creds; - if (!username || !password) return null; + const base = REGION_BASES[(creds["region"] ?? "eu").toLowerCase()] ?? REGION_BASES["eu"]!; try { - const resp = await fetch(`${this.apiBase(creds)}/v3/users/login/v2`, { + const resp = await fetch(`${base}/api/hccgw/platform/v1/token/get`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - account: username, - password, - featureCode: "deadbeef", - }), + body: JSON.stringify({ appKey, secretKey }), + signal: AbortSignal.timeout(10000), }); if (!resp.ok) return null; const data = await resp.json() as any; - return data?.data?.accessToken ?? data?.accessToken ?? null; + if (data.errorCode !== "0" || !data.data?.accessToken) return null; + return { + accessToken: data.data.accessToken, + areaDomain: data.data.areaDomain ?? base, + }; } catch { return null; }