mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +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
|
* Auth: appKey (AK) + secretKey (SK) → accessToken + areaDomain.
|
||||||
* → access token. Device list returns serials, names, online status.
|
* Camera list: POST /api/hccgw/resource/v1/areas/cameras/get (paginated).
|
||||||
* Streaming: request HLS preview URL via /v3/open/devices/:serial/previewURLs.
|
* Live view: POST /api/hccgw/video/v1/live/address/get → RTMP URL.
|
||||||
* URLs are session-based and expire — kiosk must refresh via server API.
|
|
||||||
*
|
*
|
||||||
* 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";
|
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 {
|
export class HikConnectProvider implements CloudCameraProvider {
|
||||||
vendor: CloudVendor = "hikconnect";
|
vendor: CloudVendor = "hikconnect";
|
||||||
|
|
||||||
credentialFields() {
|
credentialFields() {
|
||||||
return [
|
return [
|
||||||
{ name: "username", label: "Hik-Connect Email/Phone", type: "email" as const, required: true },
|
{ name: "app_key", label: "App Key (AK)", type: "text" as const, required: true },
|
||||||
{ name: "password", label: "Password", type: "password" as const, required: true },
|
{ name: "secret_key", label: "Secret Key (SK)", type: "password" as const, required: true },
|
||||||
{ name: "region", label: "Region (eu/us/ap)", type: "text" as const, required: false },
|
{ 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 }> {
|
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const token = await this.login(creds);
|
const auth = await this.getToken(creds);
|
||||||
return token ? { ok: true } : { ok: false, error: "Login failed" };
|
return auth ? { ok: true } : { ok: false, error: "Token request failed" };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message };
|
return { ok: false, error: (e as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
|
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
|
||||||
const token = await this.login(creds);
|
const auth = await this.getToken(creds);
|
||||||
if (!token) return [];
|
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[] = [];
|
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({
|
cameras.push({
|
||||||
vendor_id: serial,
|
vendor_id: cam.id,
|
||||||
name: d.deviceName ?? d.name ?? "Hikvision Camera",
|
name: cam.name ?? `Hik Ch${channelNo}`,
|
||||||
model: d.deviceModel ?? d.model ?? null,
|
model: null,
|
||||||
rtsp_url: null,
|
rtsp_url: null,
|
||||||
relay_url: streamUrl,
|
relay_url: streamUrl,
|
||||||
online: d.status === "online" || d.online === true,
|
online: cam.online === "1",
|
||||||
stream_type: streamUrl ? "hls" : null,
|
stream_type: streamType,
|
||||||
extra: { serial, type: d.deviceType, local_ip: d.localIp ?? d.ip ?? null },
|
extra: {
|
||||||
|
serial,
|
||||||
|
channel_no: channelNo,
|
||||||
|
ability_set: cam.abilitySet ?? "",
|
||||||
|
area_name: cam.area?.name ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cameras;
|
|
||||||
} catch {
|
const total = data.data?.totalCount ?? 0;
|
||||||
return [];
|
if (pageIndex * pageSize >= total) break;
|
||||||
|
pageIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cameras;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
|
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
|
||||||
const token = await this.login(creds);
|
const auth = await this.getToken(creds);
|
||||||
if (!token) return null;
|
if (!auth) return null;
|
||||||
return this.fetchPreviewUrl(this.apiBase(creds), token, vendorCameraId);
|
|
||||||
|
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 {
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "Token": token },
|
||||||
"Authorization": `Bearer ${token}`,
|
body: JSON.stringify({
|
||||||
"Content-Type": "application/json",
|
resourceId,
|
||||||
},
|
deviceSerial,
|
||||||
body: JSON.stringify({ protocol: "hls", quality: 1 }),
|
type: "1",
|
||||||
|
protocol: 3,
|
||||||
|
quality: "1",
|
||||||
|
expireTime: 86400,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.errorCode !== "0" || !data.data?.url) return null;
|
||||||
|
return { url: data.data.url, expireTime: data.data.expireTime ?? 0 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const base = REGION_BASES[(creds["region"] ?? "eu").toLowerCase()] ?? REGION_BASES["eu"]!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${base}/api/hccgw/platform/v1/token/get`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ appKey, secretKey }),
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
const data = await resp.json() as any;
|
const data = await resp.json() as any;
|
||||||
return data?.data?.url ?? data?.url ?? null;
|
if (data.errorCode !== "0" || !data.data?.accessToken) return null;
|
||||||
} catch {
|
return {
|
||||||
return null;
|
accessToken: data.data.accessToken,
|
||||||
}
|
areaDomain: data.data.areaDomain ?? base,
|
||||||
}
|
};
|
||||||
|
|
||||||
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 login(creds: Record<string, string>): Promise<string | null> {
|
|
||||||
const { username, password } = creds;
|
|
||||||
if (!username || !password) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${this.apiBase(creds)}/v3/users/login/v2`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
account: username,
|
|
||||||
password,
|
|
||||||
featureCode: "deadbeef",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
return data?.data?.accessToken ?? data?.accessToken ?? null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue