mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
fix(hikconnect): rewrite to HikCentral Connect OpenAPI v2.15
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) <noreply@anthropic.com>
This commit is contained in:
parent
9006364d5e
commit
1421feb7b4
1 changed files with 134 additions and 61 deletions
|
|
@ -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<string, string> = {
|
||||
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<string, string>): 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<string, string>): Promise<CloudCamera[]> {
|
||||
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`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
const devices = data?.data?.list ?? data?.deviceList ?? [];
|
||||
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: {
|
||||
"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) 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";
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of devices) {
|
||||
const serial = d.deviceSerial ?? d.serial ?? String(d.id);
|
||||
const streamUrl = await this.fetchPreviewUrl(this.apiBase(creds), token, serial);
|
||||
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<string, string>, vendorCameraId: string): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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, string>): 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<string, string>): 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<string, string>): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue