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:
Mitchell R 2026-05-23 12:16:42 +02:00
parent 9006364d5e
commit 1421feb7b4
No known key found for this signature in database

View file

@ -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: 30s720d
* - 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;
}