mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +00:00
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
|
|
/**
|
||
|
|
* Tuya IoT Cloud camera integration.
|
||
|
|
*
|
||
|
|
* Best-documented cloud API of the five vendors. OAuth2 auth with
|
||
|
|
* HMAC-SHA256 signing. Camera streaming via /v1.0/users/{uid}/devices/
|
||
|
|
* {device_id}/stream/actions/allocate → returns RTSP/HLS URLs.
|
||
|
|
*
|
||
|
|
* All auth on server — kiosk only gets streaming URLs in the bundle.
|
||
|
|
*
|
||
|
|
* Requires Tuya IoT developer account + IoT Video Live Stream subscription.
|
||
|
|
* API base: https://openapi.tuyaeu.com (EU), openapi.tuyaus.com (US), etc.
|
||
|
|
*/
|
||
|
|
import { createHmac } from "node:crypto";
|
||
|
|
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
|
||
|
|
|
||
|
|
const REGION_BASES: Record<string, string> = {
|
||
|
|
eu: "https://openapi.tuyaeu.com",
|
||
|
|
us: "https://openapi.tuyaus.com",
|
||
|
|
cn: "https://openapi.tuyacn.com",
|
||
|
|
in: "https://openapi.tuyain.com",
|
||
|
|
};
|
||
|
|
|
||
|
|
export class TuyaProvider implements CloudCameraProvider {
|
||
|
|
vendor: CloudVendor = "tuya";
|
||
|
|
|
||
|
|
credentialFields() {
|
||
|
|
return [
|
||
|
|
{ name: "client_id", label: "Access ID (Client ID)", type: "text" as const, required: true },
|
||
|
|
{ name: "client_secret", label: "Access Secret", type: "password" as const, required: true },
|
||
|
|
{ name: "region", label: "Region (eu/us/cn/in)", type: "text" as const, required: false },
|
||
|
|
{ name: "uid", label: "User UID (from Tuya app)", type: "text" as const, required: false },
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
|
||
|
|
try {
|
||
|
|
const token = await this.getToken(creds);
|
||
|
|
return token ? { 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.getToken(creds);
|
||
|
|
if (!token) return [];
|
||
|
|
const uid = creds["uid"];
|
||
|
|
if (!uid) return [];
|
||
|
|
|
||
|
|
const base = this.apiBase(creds);
|
||
|
|
const resp = await this.signedGet(`${base}/v1.0/users/${uid}/devices`, creds, token);
|
||
|
|
if (!resp) return [];
|
||
|
|
|
||
|
|
const devices = resp.result ?? [];
|
||
|
|
return devices
|
||
|
|
.filter((d: any) => d.category === "sp" || d.category === "ipc") // smart camera categories
|
||
|
|
.map((d: any) => ({
|
||
|
|
vendor_id: d.id,
|
||
|
|
name: d.name ?? "Tuya Camera",
|
||
|
|
model: d.product_name ?? d.model ?? null,
|
||
|
|
rtsp_url: null, // fetched on demand via getStreamUrl
|
||
|
|
relay_url: null,
|
||
|
|
online: d.online === true,
|
||
|
|
extra: { category: d.category, product_id: d.product_id },
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
|
||
|
|
const token = await this.getToken(creds);
|
||
|
|
if (!token) return null;
|
||
|
|
const uid = creds["uid"];
|
||
|
|
if (!uid) return null;
|
||
|
|
|
||
|
|
const base = this.apiBase(creds);
|
||
|
|
const resp = await this.signedPost(
|
||
|
|
`${base}/v1.0/users/${uid}/devices/${vendorCameraId}/stream/actions/allocate`,
|
||
|
|
creds, token,
|
||
|
|
{ type: "rtsp" },
|
||
|
|
);
|
||
|
|
return resp?.result?.url ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private apiBase(creds: Record<string, string>): string {
|
||
|
|
return REGION_BASES[(creds["region"] ?? "eu").toLowerCase()] ?? REGION_BASES["eu"]!;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async getToken(creds: Record<string, string>): Promise<string | null> {
|
||
|
|
const { client_id, client_secret } = creds;
|
||
|
|
if (!client_id || !client_secret) return null;
|
||
|
|
const base = this.apiBase(creds);
|
||
|
|
const ts = Date.now().toString();
|
||
|
|
const sign = this.calcSign(client_id, client_secret, ts, "");
|
||
|
|
|
||
|
|
const resp = await fetch(`${base}/v1.0/token?grant_type=1`, {
|
||
|
|
method: "GET",
|
||
|
|
headers: {
|
||
|
|
"client_id": client_id,
|
||
|
|
"sign": sign,
|
||
|
|
"t": ts,
|
||
|
|
"sign_method": "HMAC-SHA256",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
if (!resp.ok) return null;
|
||
|
|
const data = await resp.json() as any;
|
||
|
|
return data?.result?.access_token ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private calcSign(clientId: string, secret: string, ts: string, token: string): string {
|
||
|
|
const str = clientId + token + ts;
|
||
|
|
return createHmac("sha256", secret).update(str).digest("hex").toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async signedGet(url: string, creds: Record<string, string>, token: string): Promise<any> {
|
||
|
|
const ts = Date.now().toString();
|
||
|
|
const sign = this.calcSign(creds["client_id"]!, creds["client_secret"]!, ts, token);
|
||
|
|
const resp = await fetch(url, {
|
||
|
|
headers: {
|
||
|
|
"client_id": creds["client_id"]!,
|
||
|
|
"access_token": token,
|
||
|
|
"sign": sign,
|
||
|
|
"t": ts,
|
||
|
|
"sign_method": "HMAC-SHA256",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
if (!resp.ok) return null;
|
||
|
|
return resp.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async signedPost(url: string, creds: Record<string, string>, token: string, body: any): Promise<any> {
|
||
|
|
const ts = Date.now().toString();
|
||
|
|
const sign = this.calcSign(creds["client_id"]!, creds["client_secret"]!, ts, token);
|
||
|
|
const resp = await fetch(url, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"client_id": creds["client_id"]!,
|
||
|
|
"access_token": token,
|
||
|
|
"sign": sign,
|
||
|
|
"t": ts,
|
||
|
|
"sign_method": "HMAC-SHA256",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
},
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
});
|
||
|
|
if (!resp.ok) return null;
|
||
|
|
return resp.json();
|
||
|
|
}
|
||
|
|
}
|