From a9484d1dd7595bdee3ede42edefb60b85279d604 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 11:36:49 +0200 Subject: [PATCH] feat(cloud-cameras): type=cloud + bidirectional sync + PG default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud cameras are now a distinct type ('cloud') managed entirely by sync. Bidirectional: cameras added in vendor cloud appear automatically, removed cameras get deleted. Cloud cameras and their entities are read-only in admin UI — no manual editing. - Camera type CHECK widened to include 'cloud' - New columns: cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type - Repo: upsertCloudCamera, deleteCloudCamerasNotIn, listCloudCamerasByAccount - Sync replaces import: full reconciliation per account - Hik-Connect: fetch HLS preview URLs via previewURLs endpoint - Tuya: fetch stream URLs during sync (not just on demand) - Kiosk API: GET /api/kiosk/cameras/:id/stream returns fresh relay URL from vendor (session-based URLs expire) - Cloud cameras show read-only detail page with cloud badge - Coolify compose: postgres 18 as default, BF_DB=postgres, server depends_on postgres healthy Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.coolify.yml | 11 +- .../service-admin-http/routes-admin.ts | 5 +- .../service-admin-http/routes-cloud.ts | 127 ++++++++---------- server/src/plugins/service-api-http/index.ts | 38 +++++- server/src/plugins/service-store/mappers.ts | 4 + .../plugins/service-store/migrations-pg.ts | 9 +- .../src/plugins/service-store/migrations.ts | 61 +++++++++ .../src/plugins/service-store/repository.ts | 60 +++++++++ server/src/shared/cloud-cameras/dahua.ts | 1 + server/src/shared/cloud-cameras/hikconnect.ts | 69 ++++++---- server/src/shared/cloud-cameras/tplink.ts | 1 + server/src/shared/cloud-cameras/tuya.ts | 24 +++- server/src/shared/cloud-cameras/types.ts | 7 +- server/src/shared/cloud-cameras/uniview.ts | 2 + server/src/shared/types.ts | 6 +- server/src/web-templates/admin-pages.tsx | 32 ++++- 16 files changed, 335 insertions(+), 122 deletions(-) diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 1675464..9d1c8ec 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -29,7 +29,7 @@ services: - BF_SELF_URL=http://server:18080 - BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}} # PostgreSQL: set BF_DB=postgres to switch from SQLite. - - BF_DB=${BF_DB:-sqlite} + - BF_DB=postgres - BF_PG_URL=${BF_PG_URL:-postgres://${BF_PG_USER:-betterframe}:${BF_PG_PASSWORD:-betterframe}@postgres:5432/${BF_PG_DB:-betterframe}} volumes: - betterframe-data:/var/lib/betterframe @@ -37,6 +37,9 @@ services: - "18080" - "18081" - "18082" + depends_on: + postgres: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"] interval: 30s @@ -83,10 +86,8 @@ services: networks: - betterframe - # PostgreSQL — optional. Set BF_DB=postgres to switch from SQLite. - # Omit or disable this service to keep using SQLite (default). postgres: - image: postgres:17-alpine + image: postgres:18-alpine container_name: betterframe-postgres restart: unless-stopped environment: @@ -105,8 +106,6 @@ services: start_period: 10s networks: - betterframe - profiles: - - postgres volumes: betterframe-data: diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index a299578..69644f6 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1404,8 +1404,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cameras/:id", async (event) => { const id = Number(getRouterParam(event, "id")); - const body = await readBody>(event); const cam = await deps.repo.getCameraById(id); + if (cam?.type === "cloud") { + return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); + } + const body = await readBody>(event); let rtspUrl: string | null = null; if (cam?.type === "rtsp") { diff --git a/server/src/plugins/service-admin-http/routes-cloud.ts b/server/src/plugins/service-admin-http/routes-cloud.ts index 5995734..1359354 100644 --- a/server/src/plugins/service-admin-http/routes-cloud.ts +++ b/server/src/plugins/service-admin-http/routes-cloud.ts @@ -14,6 +14,60 @@ import type { AdminDeps } from "./index.js"; import { CLOUD_VENDORS, VENDOR_LABELS, getProvider, listProviders, type CloudVendor } from "../../shared/cloud-cameras/index.js"; import { CloudAccountsPage } from "../../web-templates/admin-pages.js"; +/** + * Full bidirectional sync: cloud state → local cameras. + * Creates new cameras, updates existing, deletes removed. + */ +async function syncCloudAccount(accountId: string, deps: AdminDeps): Promise { + const account = await deps.repo.getCloudAccount(accountId); + if (!account) return; + + const provider = getProvider(account.vendor as CloudVendor); + if (!provider) { + await deps.repo.updateCloudAccount(accountId, { last_sync_error: "unknown vendor" } as any); + return; + } + + let creds: Record; + try { + creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds")); + } catch { + await deps.repo.updateCloudAccount(accountId, { last_sync_error: "credential decrypt failed" } as any); + return; + } + + try { + const cloudCameras = await provider.listCameras(creds); + const vendorIds: string[] = []; + + for (const cam of cloudCameras) { + vendorIds.push(cam.vendor_id); + const streamUrl = cam.rtsp_url ?? cam.relay_url ?? null; + await deps.repo.upsertCloudCamera({ + cloud_account_id: accountId, + cloud_vendor_camera_id: cam.vendor_id, + name: `${account.name}: ${cam.name}`, + cloud_stream_url: streamUrl, + cloud_stream_type: cam.stream_type ?? (streamUrl ? "rtsp" : null), + enabled: cam.online, + }); + } + + const removed = await deps.repo.deleteCloudCamerasNotIn(accountId, vendorIds); + + await deps.repo.updateCloudAccount(accountId, { + camera_count: cloudCameras.length, + last_sync_at: new Date().toISOString(), + last_sync_error: null, + } as any); + } catch (err) { + await deps.repo.updateCloudAccount(accountId, { + last_sync_error: (err as Error).message, + last_sync_at: new Date().toISOString(), + } as any); + } +} + export function registerCloudRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/cloud-accounts", async (event) => { @@ -80,81 +134,10 @@ export function registerCloudRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cloud-accounts/:id/sync", async (event) => { const id = String(getRouterParam(event, "id")); - const account = await deps.repo.getCloudAccount(id); - if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - - const provider = getProvider(account.vendor as CloudVendor); - if (!provider) { - await deps.repo.updateCloudAccount(id, { last_sync_error: "unknown vendor" }); - return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - } - - let creds: Record; - try { - creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds")); - } catch { - await deps.repo.updateCloudAccount(id, { last_sync_error: "credential decrypt failed" }); - return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - } - - try { - const cameras = await provider.listCameras(creds); - await deps.repo.updateCloudAccount(id, { - camera_count: cameras.length, - last_sync_at: new Date().toISOString(), - last_sync_error: null, - } as any); - } catch (err) { - await deps.repo.updateCloudAccount(id, { - last_sync_error: (err as Error).message, - last_sync_at: new Date().toISOString(), - } as any); - } - + await syncCloudAccount(id, deps); return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); }); - app.post("/admin/cloud-accounts/:id/import", async (event) => { - const id = String(getRouterParam(event, "id")); - const account = await deps.repo.getCloudAccount(id); - if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - - const provider = getProvider(account.vendor as CloudVendor); - if (!provider) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - - let creds: Record; - try { - creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds")); - } catch { - return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); - } - - const cameras = await provider.listCameras(creds); - let imported = 0; - for (const cam of cameras) { - if (!cam.rtsp_url && !cam.relay_url) continue; - // Check if already imported (by vendor_id in camera name prefix). - const existingName = `${account.name}: ${cam.name}`; - const existing = await deps.repo.getCameraByName(existingName); - if (existing) continue; - - await deps.repo.createCamera({ - name: existingName, - type: "rtsp", - rtsp_url: cam.rtsp_url ?? cam.relay_url ?? null, - }); - imported++; - } - - await deps.repo.updateCloudAccount(id, { - camera_count: cameras.length, - last_sync_at: new Date().toISOString(), - last_sync_error: null, - } as any); - - return new Response(null, { status: 302, headers: { location: `/admin/cloud-accounts` } }); - }); - app.post("/admin/cloud-accounts/:id/delete", async (event) => { const id = String(getRouterParam(event, "id")); await deps.repo.deleteCloudAccount(id); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 0ff7039..ca13ee5 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -12,7 +12,7 @@ import { createEventSchemas, type Observable, } from "@bsb/base"; -import { H3, serve, readBody, getRequestHeader, createError } from "h3"; +import { H3, serve, readBody, getRequestHeader, getRouterParam, createError } from "h3"; import type { Server } from "srvx"; import { getRepo } from "../../shared/plugin-registry.js"; @@ -932,6 +932,42 @@ function registerKioskRoutes( }); return { ok: true }; }); + + app.get("/api/kiosk/cameras/:id/stream", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + const kiosk = await auth.verifyKioskKey(token); + if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + + const cameraId = Number(getRouterParam(event, "id")); + const camera = await repo.getCameraById(cameraId); + if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) { + throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" }); + } + + const account = await repo.getCloudAccount(camera.cloud_account_id); + if (!account) throw createError({ statusCode: 404, statusMessage: "Cloud account not found" }); + + const { getProvider: gp } = await import("../../shared/cloud-cameras/index.js"); + const provider = gp(account.vendor as any); + if (!provider) throw createError({ statusCode: 500, statusMessage: "Unknown vendor" }); + + let creds: Record; + try { + creds = JSON.parse(secrets.decryptString(account.credentials_encrypted, "cloud-creds")); + } catch { + throw createError({ statusCode: 500, statusMessage: "Credential decrypt failed" }); + } + + const url = await provider.getStreamUrl(creds, camera.cloud_vendor_camera_id); + if (!url) throw createError({ statusCode: 503, statusMessage: "Stream URL unavailable" }); + + if (url !== camera.cloud_stream_url) { + await repo.updateCamera(camera.id, { cloud_stream_url: url } as any); + } + + return { url, stream_type: camera.cloud_stream_type ?? "hls" }; + }); } /** diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 0a7724c..00321eb 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -167,6 +167,10 @@ export function rowToCamera(r: Row): Camera { event_source: s(r["event_source"] ?? "auto"), event_sink: s(r["event_sink"] ?? "auto"), supported_event_topics: j(r["supported_event_topics"], []), + cloud_account_id: sn(r["cloud_account_id"]), + cloud_vendor_camera_id: sn(r["cloud_vendor_camera_id"]), + cloud_stream_url: sn(r["cloud_stream_url"]), + cloud_stream_type: sn(r["cloud_stream_type"]), }; } diff --git a/server/src/plugins/service-store/migrations-pg.ts b/server/src/plugins/service-store/migrations-pg.ts index 0146def..1cf5a25 100644 --- a/server/src/plugins/service-store/migrations-pg.ts +++ b/server/src/plugins/service-store/migrations-pg.ts @@ -149,7 +149,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ `CREATE TABLE IF NOT EXISTS cameras ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif')), + type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')), rtsp_url TEXT, onvif_host TEXT, onvif_port INTEGER, @@ -163,8 +163,13 @@ export const TENANT_MIGRATIONS: readonly string[] = [ created_at TIMESTAMPTZ NOT NULL DEFAULT now(), event_source TEXT NOT NULL DEFAULT 'auto', event_sink TEXT NOT NULL DEFAULT 'auto', - supported_event_topics JSONB NOT NULL DEFAULT '[]' + supported_event_topics JSONB NOT NULL DEFAULT '[]', + cloud_account_id TEXT, + cloud_vendor_camera_id TEXT, + cloud_stream_url TEXT, + cloud_stream_type TEXT )`, + `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`, `CREATE TABLE IF NOT EXISTS camera_streams ( id SERIAL PRIMARY KEY, diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 11989c6..22d75da 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -1013,4 +1013,65 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); }, + + // Cloud camera type + cloud-linked fields. Rebuild cameras table to add + // 'cloud' to type CHECK. Cloud cameras are managed by sync — not editable. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "cameras", "cloud_account_id", "TEXT"); + addColumnIfNotExists(db, "cameras", "cloud_vendor_camera_id", "TEXT"); + addColumnIfNotExists(db, "cameras", "cloud_stream_url", "TEXT"); + addColumnIfNotExists(db, "cameras", "cloud_stream_type", "TEXT"); + + // Rebuild to widen CHECK constraint to include 'cloud'. + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'cameras'") + .get() as { sql?: string } | undefined; + if (!row?.sql || row.sql.includes("'cloud'")) return; + + db.exec("PRAGMA foreign_keys = OFF"); + db.exec(` + CREATE TABLE cameras_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')), + rtsp_url TEXT, + onvif_host TEXT, + onvif_port INTEGER, + onvif_username TEXT, + onvif_password TEXT, + capabilities TEXT NOT NULL DEFAULT '[]', + stream_policy TEXT NOT NULL DEFAULT 'auto' + CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')), + enabled INTEGER NOT NULL DEFAULT 1, + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + event_source TEXT NOT NULL DEFAULT 'auto', + event_sink TEXT NOT NULL DEFAULT 'auto', + supported_event_topics TEXT NOT NULL DEFAULT '[]', + cloud_account_id TEXT, + cloud_vendor_camera_id TEXT, + cloud_stream_url TEXT, + cloud_stream_type TEXT + ) STRICT; + + INSERT INTO cameras_new ( + id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, + capabilities, stream_policy, enabled, last_seen_at, created_at, + event_source, event_sink, supported_event_topics, + cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type + ) + SELECT + id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password, + capabilities, stream_policy, enabled, last_seen_at, created_at, + event_source, event_sink, supported_event_topics, + cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type + FROM cameras; + + DROP TABLE cameras; + ALTER TABLE cameras_new RENAME TO cameras; + `); + db.exec("PRAGMA foreign_keys = ON"); + }, + `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`, + `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_vendor ON cameras(cloud_account_id, cloud_vendor_camera_id)`, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 9d2ec7e..efa4388 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -920,6 +920,66 @@ export class Repository { return c; } + async upsertCloudCamera(input: { + cloud_account_id: string; + cloud_vendor_camera_id: string; + name: string; + cloud_stream_url: string | null; + cloud_stream_type: string | null; + enabled: boolean; + }): Promise { + const existing = await this._get( + "SELECT * FROM cameras WHERE cloud_account_id = ? AND cloud_vendor_camera_id = ?", + [input.cloud_account_id, input.cloud_vendor_camera_id], + ); + if (existing) { + const cam = rowToCamera(existing as Record); + await this._run( + `UPDATE cameras SET name = ?, cloud_stream_url = ?, cloud_stream_type = ?, enabled = ? WHERE id = ?`, + [input.name, input.cloud_stream_url, input.cloud_stream_type, B(input.enabled), cam.id], + ); + void this.notify("cameras", "update", cam.id); + return (await this.getCameraById(cam.id))!; + } + const result = await this._run( + `INSERT INTO cameras + (name, type, cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type, enabled) + VALUES (?, 'cloud', ?, ?, ?, ?, ?)`, + [input.name, input.cloud_account_id, input.cloud_vendor_camera_id, + input.cloud_stream_url, input.cloud_stream_type, B(input.enabled)], + ); + const id = Number(result.lastInsertRowid); + void this.notify("cameras", "create", id); + const c = await this.getCameraById(id); + if (!c) throw new Error("cloud camera vanished after insert"); + await this.ensureCameraEntity(c); + return c; + } + + async listCloudCamerasByAccount(accountId: string): Promise { + const rs = await this._all( + "SELECT * FROM cameras WHERE cloud_account_id = ? ORDER BY name", + [accountId], + ); + return rs.map((r) => rowToCamera(r as Record)); + } + + async deleteCloudCamerasNotIn(accountId: string, keepVendorIds: string[]): Promise { + if (keepVendorIds.length === 0) { + const result = await this._run( + "DELETE FROM cameras WHERE cloud_account_id = ?", + [accountId], + ); + return result.changes; + } + const placeholders = keepVendorIds.map(() => "?").join(","); + const result = await this._run( + `DELETE FROM cameras WHERE cloud_account_id = ? AND cloud_vendor_camera_id NOT IN (${placeholders})`, + [accountId, ...keepVendorIds], + ); + return result.changes; + } + async listCameraStreams(cameraId: number): Promise { const rs = await this._all( "SELECT * FROM camera_streams WHERE camera_id = ?", diff --git a/server/src/shared/cloud-cameras/dahua.ts b/server/src/shared/cloud-cameras/dahua.ts index 4885e74..20965f0 100644 --- a/server/src/shared/cloud-cameras/dahua.ts +++ b/server/src/shared/cloud-cameras/dahua.ts @@ -62,6 +62,7 @@ export class DahuaProvider implements CloudCameraProvider { rtsp_url: rtspUrl, relay_url: null, online: true, + stream_type: "rtsp", extra: { channel: ch }, }); } diff --git a/server/src/shared/cloud-cameras/hikconnect.ts b/server/src/shared/cloud-cameras/hikconnect.ts index 0317185..70ba73f 100644 --- a/server/src/shared/cloud-cameras/hikconnect.ts +++ b/server/src/shared/cloud-cameras/hikconnect.ts @@ -1,16 +1,12 @@ /** * Hik-Connect (Hikvision cloud) integration. * - * Hikvision uses a proprietary cloud API at api.hik-connect.com. - * Auth: username/password → session token. No public OAuth. - * Camera list: GET /v3/userdevices/v1/devices/list - * Streaming: cameras expose RTSP locally; cloud relay uses P2P via - * Hik-Connect SDK (native, not web-friendly). For BetterFrame we - * extract the device serial + verify credentials, then assume - * local RTSP access (most Hik-Connect cameras are on the same LAN - * as the kiosk). If not on LAN, need ISAPI relay. + * 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 keys stay on server — kiosk only gets RTSP URLs. + * All auth on server — kiosk only gets HLS URLs in the bundle. */ import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js"; @@ -50,31 +46,58 @@ export class HikConnectProvider implements CloudCameraProvider { if (!resp.ok) return []; const data = await resp.json() as any; const devices = data?.data?.list ?? data?.deviceList ?? []; - return devices.map((d: any) => ({ - vendor_id: d.deviceSerial ?? d.serial ?? String(d.id), - name: d.deviceName ?? d.name ?? "Hikvision Camera", - model: d.deviceModel ?? d.model ?? null, - rtsp_url: null, // Hik-Connect doesn't expose RTSP URLs — local ONVIF needed - relay_url: null, - online: d.status === "online" || d.online === true, - extra: { serial: d.deviceSerial, type: d.deviceType }, - })); + 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); + cameras.push({ + vendor_id: serial, + name: d.deviceName ?? d.name ?? "Hikvision Camera", + model: d.deviceModel ?? d.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 }, + }); + } + return cameras; } catch { return []; } } async getStreamUrl(creds: Record, vendorCameraId: string): Promise { - // Hik-Connect uses P2P relay via native SDK — no direct RTSP from cloud. - // Kiosk needs local ONVIF/RTSP access. Return null to signal "use local". - return null; + const token = await this.login(creds); + if (!token) return null; + return this.fetchPreviewUrl(this.apiBase(creds), token, vendorCameraId); + } + + private async fetchPreviewUrl(base: string, token: string, serial: string): Promise { + try { + const resp = await fetch(`${base}/v3/open/devices/${serial}/previewURLs`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ protocol: "hls", quality: 1 }), + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return null; + const data = await resp.json() as any; + return data?.data?.url ?? data?.url ?? null; + } 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 "https://api.hik-connect.com"; // EU is default + return API_BASE; } private async login(creds: Record): Promise { @@ -88,7 +111,7 @@ export class HikConnectProvider implements CloudCameraProvider { body: JSON.stringify({ account: username, password, - featureCode: "deadbeef", // required by API + featureCode: "deadbeef", }), }); if (!resp.ok) return null; diff --git a/server/src/shared/cloud-cameras/tplink.ts b/server/src/shared/cloud-cameras/tplink.ts index 8d15cd8..eab9f86 100644 --- a/server/src/shared/cloud-cameras/tplink.ts +++ b/server/src/shared/cloud-cameras/tplink.ts @@ -46,6 +46,7 @@ export class TpLinkProvider implements CloudCameraProvider { rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}/stream1`, relay_url: null, online: true, + stream_type: "rtsp", extra: {}, }]; } diff --git a/server/src/shared/cloud-cameras/tuya.ts b/server/src/shared/cloud-cameras/tuya.ts index 3012fe7..c5846ac 100644 --- a/server/src/shared/cloud-cameras/tuya.ts +++ b/server/src/shared/cloud-cameras/tuya.ts @@ -52,17 +52,27 @@ export class TuyaProvider implements CloudCameraProvider { if (!resp) return []; const devices = resp.result ?? []; - return devices - .filter((d: any) => d.category === "sp" || d.category === "ipc") // smart camera categories - .map((d: any) => ({ + const cameraDevices = devices.filter( + (d: any) => d.category === "sp" || d.category === "ipc", + ); + const cameras: CloudCamera[] = []; + for (const d of cameraDevices) { + let streamUrl: string | null = null; + if (d.online === true) { + streamUrl = await this.getStreamUrl(creds, d.id); + } + cameras.push({ 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, + rtsp_url: null, + relay_url: streamUrl, online: d.online === true, - extra: { category: d.category, product_id: d.product_id }, - })); + stream_type: streamUrl ? "rtsp" : null, + extra: { category: d.category, product_id: d.product_id, local_ip: d.ip ?? null }, + }); + } + return cameras; } async getStreamUrl(creds: Record, vendorCameraId: string): Promise { diff --git a/server/src/shared/cloud-cameras/types.ts b/server/src/shared/cloud-cameras/types.ts index 1673acf..279f108 100644 --- a/server/src/shared/cloud-cameras/types.ts +++ b/server/src/shared/cloud-cameras/types.ts @@ -32,17 +32,16 @@ export const VENDOR_LABELS: Record = { tplink: "TP-Link (Tapo/VIGI)", }; +export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null; + export interface CloudCamera { - /** Vendor-specific unique ID for this camera. */ vendor_id: string; name: string; model: string | null; - /** Direct RTSP URL if the vendor provides one. */ rtsp_url: string | null; - /** Vendor-specific relay/streaming URL (e.g. HLS, RTMP). */ relay_url: string | null; online: boolean; - /** Additional vendor-specific metadata. */ + stream_type: CloudStreamType; extra: Record; } diff --git a/server/src/shared/cloud-cameras/uniview.ts b/server/src/shared/cloud-cameras/uniview.ts index 3bcf467..3752c32 100644 --- a/server/src/shared/cloud-cameras/uniview.ts +++ b/server/src/shared/cloud-cameras/uniview.ts @@ -57,6 +57,7 @@ export class UniviewProvider implements CloudCameraProvider { rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}:554/media/video${chId}`, relay_url: null, online: ch.Online === true || ch.Status === "Online", + stream_type: "rtsp", extra: { channel_id: chId }, }); } @@ -69,6 +70,7 @@ export class UniviewProvider implements CloudCameraProvider { rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}:554/media/video1`, relay_url: null, online: true, + stream_type: "rtsp", extra: {}, }); } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 904c936..7cf04fe 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -7,7 +7,7 @@ export type UserRole = "admin" | "operator"; export type ApiKeyScope = "read" | "control" | "admin"; -export type CameraType = "rtsp" | "onvif"; +export type CameraType = "rtsp" | "onvif" | "cloud"; export type StreamRole = "main" | "sub" | "other"; export type StreamSelector = "auto" | "main" | "sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub"; @@ -126,6 +126,10 @@ export interface Camera { event_source: EventSourceMode; event_sink: EventSinkMode; supported_event_topics: string[]; + cloud_account_id: string | null; + cloud_vendor_camera_id: string | null; + cloud_stream_url: string | null; + cloud_stream_type: string | null; } export interface CameraStream { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 9cd18b8..228f91f 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1245,6 +1245,30 @@ export function CameraEditPage(props: CameraEditProps) { } >
+ {cam.type === "cloud" && ( +
+

+ Cloud + {cam.name} +

+

+ This camera is managed by a cloud account sync. It cannot be edited manually. + Changes are applied automatically when the cloud account is synced. +

+
+
Type: Cloud ({cam.cloud_stream_type ?? "unknown stream"})
+
Vendor Camera ID: {cam.cloud_vendor_camera_id ?? "—"}
+ {cam.cloud_stream_url && ( +
Stream URL: {cam.cloud_stream_url}
+ )} +
Status: {cam.enabled ? Enabled : Disabled}
+
Last seen: {cam.last_seen_at ? formatTime(cam.last_seen_at) : "Never"}
+
+ Back +
+ )} + + {cam.type !== "cloud" && (<>

Edit Camera

@@ -1459,6 +1483,7 @@ export function CameraEditPage(props: CameraEditProps) {
+ )}
); @@ -3956,13 +3981,10 @@ export function CloudAccountsPage(props: CloudAccountsPageProps) {
- -
-
- +
+ {...{"onsubmit": "return confirm('Delete this cloud account and all its synced cameras?')"}}>