feat(cloud-cameras): type=cloud + bidirectional sync + PG default

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) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-23 11:36:49 +02:00
parent 827ed39514
commit a9484d1dd7
No known key found for this signature in database
16 changed files with 335 additions and 122 deletions

View file

@ -29,7 +29,7 @@ services:
- BF_SELF_URL=http://server:18080 - BF_SELF_URL=http://server:18080
- BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}} - BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}}
# PostgreSQL: set BF_DB=postgres to switch from SQLite. # 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}} - BF_PG_URL=${BF_PG_URL:-postgres://${BF_PG_USER:-betterframe}:${BF_PG_PASSWORD:-betterframe}@postgres:5432/${BF_PG_DB:-betterframe}}
volumes: volumes:
- betterframe-data:/var/lib/betterframe - betterframe-data:/var/lib/betterframe
@ -37,6 +37,9 @@ services:
- "18080" - "18080"
- "18081" - "18081"
- "18082" - "18082"
depends_on:
postgres:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"]
interval: 30s interval: 30s
@ -83,10 +86,8 @@ services:
networks: networks:
- betterframe - betterframe
# PostgreSQL — optional. Set BF_DB=postgres to switch from SQLite.
# Omit or disable this service to keep using SQLite (default).
postgres: postgres:
image: postgres:17-alpine image: postgres:18-alpine
container_name: betterframe-postgres container_name: betterframe-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@ -105,8 +106,6 @@ services:
start_period: 10s start_period: 10s
networks: networks:
- betterframe - betterframe
profiles:
- postgres
volumes: volumes:
betterframe-data: betterframe-data:

View file

@ -1404,8 +1404,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/cameras/:id", async (event) => { app.post("/admin/cameras/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const cam = await deps.repo.getCameraById(id); 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<Record<string, string>>(event);
let rtspUrl: string | null = null; let rtspUrl: string | null = null;
if (cam?.type === "rtsp") { if (cam?.type === "rtsp") {

View file

@ -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 { CLOUD_VENDORS, VENDOR_LABELS, getProvider, listProviders, type CloudVendor } from "../../shared/cloud-cameras/index.js";
import { CloudAccountsPage } from "../../web-templates/admin-pages.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<void> {
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<string, string>;
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 { export function registerCloudRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/cloud-accounts", async (event) => { app.get("/admin/cloud-accounts", async (event) => {
@ -80,79 +134,8 @@ export function registerCloudRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/cloud-accounts/:id/sync", async (event) => { app.post("/admin/cloud-accounts/:id/sync", async (event) => {
const id = String(getRouterParam(event, "id")); const id = String(getRouterParam(event, "id"));
const account = await deps.repo.getCloudAccount(id); await syncCloudAccount(id, deps);
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" } }); return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
}
let creds: Record<string, string>;
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);
}
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<string, string>;
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) => { app.post("/admin/cloud-accounts/:id/delete", async (event) => {

View file

@ -12,7 +12,7 @@ import {
createEventSchemas, createEventSchemas,
type Observable, type Observable,
} from "@bsb/base"; } 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 type { Server } from "srvx";
import { getRepo } from "../../shared/plugin-registry.js"; import { getRepo } from "../../shared/plugin-registry.js";
@ -932,6 +932,42 @@ function registerKioskRoutes(
}); });
return { ok: true }; 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<string, string>;
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" };
});
} }
/** /**

View file

@ -167,6 +167,10 @@ export function rowToCamera(r: Row): Camera {
event_source: s(r["event_source"] ?? "auto"), event_source: s(r["event_source"] ?? "auto"),
event_sink: s(r["event_sink"] ?? "auto"), event_sink: s(r["event_sink"] ?? "auto"),
supported_event_topics: j<string[]>(r["supported_event_topics"], []), supported_event_topics: j<string[]>(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"]),
}; };
} }

View file

@ -149,7 +149,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
`CREATE TABLE IF NOT EXISTS cameras ( `CREATE TABLE IF NOT EXISTS cameras (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, 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, rtsp_url TEXT,
onvif_host TEXT, onvif_host TEXT,
onvif_port INTEGER, onvif_port INTEGER,
@ -163,8 +163,13 @@ export const TENANT_MIGRATIONS: readonly string[] = [
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_source TEXT NOT NULL DEFAULT 'auto', event_source TEXT NOT NULL DEFAULT 'auto',
event_sink 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 ( `CREATE TABLE IF NOT EXISTS camera_streams (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

View file

@ -1013,4 +1013,65 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'"); addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'");
addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'"); 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)`,
]; ];

View file

@ -920,6 +920,66 @@ export class Repository {
return c; 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<Camera> {
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<string, unknown>);
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<Camera[]> {
const rs = await this._all(
"SELECT * FROM cameras WHERE cloud_account_id = ? ORDER BY name",
[accountId],
);
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
}
async deleteCloudCamerasNotIn(accountId: string, keepVendorIds: string[]): Promise<number> {
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<CameraStream[]> { async listCameraStreams(cameraId: number): Promise<CameraStream[]> {
const rs = await this._all( const rs = await this._all(
"SELECT * FROM camera_streams WHERE camera_id = ?", "SELECT * FROM camera_streams WHERE camera_id = ?",

View file

@ -62,6 +62,7 @@ export class DahuaProvider implements CloudCameraProvider {
rtsp_url: rtspUrl, rtsp_url: rtspUrl,
relay_url: null, relay_url: null,
online: true, online: true,
stream_type: "rtsp",
extra: { channel: ch }, extra: { channel: ch },
}); });
} }

View file

@ -1,16 +1,12 @@
/** /**
* Hik-Connect (Hikvision cloud) integration. * Hik-Connect (Hikvision cloud) integration.
* *
* Hikvision uses a proprietary cloud API at api.hik-connect.com. * Hikvision cloud API at api.hik-connect.com. Auth via username/password
* Auth: username/password session token. No public OAuth. * access token. Device list returns serials, names, online status.
* Camera list: GET /v3/userdevices/v1/devices/list * Streaming: request HLS preview URL via /v3/open/devices/:serial/previewURLs.
* Streaming: cameras expose RTSP locally; cloud relay uses P2P via * URLs are session-based and expire kiosk must refresh via server API.
* 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.
* *
* 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"; import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
@ -50,31 +46,58 @@ export class HikConnectProvider implements CloudCameraProvider {
if (!resp.ok) return []; if (!resp.ok) return [];
const data = await resp.json() as any; const data = await resp.json() as any;
const devices = data?.data?.list ?? data?.deviceList ?? []; const devices = data?.data?.list ?? data?.deviceList ?? [];
return devices.map((d: any) => ({ const cameras: CloudCamera[] = [];
vendor_id: d.deviceSerial ?? d.serial ?? String(d.id),
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", name: d.deviceName ?? d.name ?? "Hikvision Camera",
model: d.deviceModel ?? d.model ?? null, model: d.deviceModel ?? d.model ?? null,
rtsp_url: null, // Hik-Connect doesn't expose RTSP URLs — local ONVIF needed rtsp_url: null,
relay_url: null, relay_url: streamUrl,
online: d.status === "online" || d.online === true, online: d.status === "online" || d.online === true,
extra: { serial: d.deviceSerial, type: d.deviceType }, stream_type: streamUrl ? "hls" : null,
})); extra: { serial, type: d.deviceType, local_ip: d.localIp ?? d.ip ?? null },
});
}
return cameras;
} catch { } catch {
return []; return [];
} }
} }
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> { async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
// Hik-Connect uses P2P relay via native SDK — no direct RTSP from cloud. const token = await this.login(creds);
// Kiosk needs local ONVIF/RTSP access. Return null to signal "use local". if (!token) return null;
return this.fetchPreviewUrl(this.apiBase(creds), token, vendorCameraId);
}
private async fetchPreviewUrl(base: string, token: string, serial: string): Promise<string | null> {
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; return null;
} }
}
private apiBase(creds: Record<string, string>): string { private apiBase(creds: Record<string, string>): string {
const region = (creds["region"] ?? "eu").toLowerCase(); const region = (creds["region"] ?? "eu").toLowerCase();
if (region === "us") return "https://api.hik-connect.com"; if (region === "us") return "https://api.hik-connect.com";
if (region === "ap") 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<string, string>): Promise<string | null> { private async login(creds: Record<string, string>): Promise<string | null> {
@ -88,7 +111,7 @@ export class HikConnectProvider implements CloudCameraProvider {
body: JSON.stringify({ body: JSON.stringify({
account: username, account: username,
password, password,
featureCode: "deadbeef", // required by API featureCode: "deadbeef",
}), }),
}); });
if (!resp.ok) return null; if (!resp.ok) return null;

View file

@ -46,6 +46,7 @@ export class TpLinkProvider implements CloudCameraProvider {
rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}/stream1`, rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}/stream1`,
relay_url: null, relay_url: null,
online: true, online: true,
stream_type: "rtsp",
extra: {}, extra: {},
}]; }];
} }

View file

@ -52,17 +52,27 @@ export class TuyaProvider implements CloudCameraProvider {
if (!resp) return []; if (!resp) return [];
const devices = resp.result ?? []; const devices = resp.result ?? [];
return devices const cameraDevices = devices.filter(
.filter((d: any) => d.category === "sp" || d.category === "ipc") // smart camera categories (d: any) => d.category === "sp" || d.category === "ipc",
.map((d: any) => ({ );
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, vendor_id: d.id,
name: d.name ?? "Tuya Camera", name: d.name ?? "Tuya Camera",
model: d.product_name ?? d.model ?? null, model: d.product_name ?? d.model ?? null,
rtsp_url: null, // fetched on demand via getStreamUrl rtsp_url: null,
relay_url: null, relay_url: streamUrl,
online: d.online === true, 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<string, string>, vendorCameraId: string): Promise<string | null> { async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {

View file

@ -32,17 +32,16 @@ export const VENDOR_LABELS: Record<CloudVendor, string> = {
tplink: "TP-Link (Tapo/VIGI)", tplink: "TP-Link (Tapo/VIGI)",
}; };
export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null;
export interface CloudCamera { export interface CloudCamera {
/** Vendor-specific unique ID for this camera. */
vendor_id: string; vendor_id: string;
name: string; name: string;
model: string | null; model: string | null;
/** Direct RTSP URL if the vendor provides one. */
rtsp_url: string | null; rtsp_url: string | null;
/** Vendor-specific relay/streaming URL (e.g. HLS, RTMP). */
relay_url: string | null; relay_url: string | null;
online: boolean; online: boolean;
/** Additional vendor-specific metadata. */ stream_type: CloudStreamType;
extra: Record<string, unknown>; extra: Record<string, unknown>;
} }

View file

@ -57,6 +57,7 @@ export class UniviewProvider implements CloudCameraProvider {
rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}:554/media/video${chId}`, rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}:554/media/video${chId}`,
relay_url: null, relay_url: null,
online: ch.Online === true || ch.Status === "Online", online: ch.Online === true || ch.Status === "Online",
stream_type: "rtsp",
extra: { channel_id: chId }, 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`, rtsp_url: `rtsp://${creds["username"]}:${creds["password"]}@${creds["host"]}:554/media/video1`,
relay_url: null, relay_url: null,
online: true, online: true,
stream_type: "rtsp",
extra: {}, extra: {},
}); });
} }

View file

@ -7,7 +7,7 @@
export type UserRole = "admin" | "operator"; export type UserRole = "admin" | "operator";
export type ApiKeyScope = "read" | "control" | "admin"; 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 StreamRole = "main" | "sub" | "other";
export type StreamSelector = "auto" | "main" | "sub"; export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub";
@ -126,6 +126,10 @@ export interface Camera {
event_source: EventSourceMode; event_source: EventSourceMode;
event_sink: EventSinkMode; event_sink: EventSinkMode;
supported_event_topics: string[]; 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 { export interface CameraStream {

View file

@ -1245,6 +1245,30 @@ export function CameraEditPage(props: CameraEditProps) {
} }
> >
<div style="max-width:700px"> <div style="max-width:700px">
{cam.type === "cloud" && (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">
<span class="badge badge-blue" style="margin-right:0.5rem">Cloud</span>
{cam.name}
</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
This camera is managed by a cloud account sync. It cannot be edited manually.
Changes are applied automatically when the cloud account is synced.
</p>
<div style="font-size:0.85rem; display:grid; gap:0.35rem; color:#444">
<div><strong>Type:</strong> Cloud ({cam.cloud_stream_type ?? "unknown stream"})</div>
<div><strong>Vendor Camera ID:</strong> <code>{cam.cloud_vendor_camera_id ?? "—"}</code></div>
{cam.cloud_stream_url && (
<div><strong>Stream URL:</strong> <code style="font-size:0.8rem; word-break:break-all">{cam.cloud_stream_url}</code></div>
)}
<div><strong>Status:</strong> {cam.enabled ? <span class="badge badge-green">Enabled</span> : <span class="badge badge-red">Disabled</span>}</div>
<div><strong>Last seen:</strong> {cam.last_seen_at ? formatTime(cam.last_seen_at) : "Never"}</div>
</div>
<a href="/admin/cameras" class="btn btn-ghost" style="margin-top:1rem">Back</a>
</div>
)}
{cam.type !== "cloud" && (<>
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Camera</h2> <h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Camera</h2>
<form method="post" action={`/admin/cameras/${cam.id}`}> <form method="post" action={`/admin/cameras/${cam.id}`}>
@ -1459,6 +1483,7 @@ export function CameraEditPage(props: CameraEditProps) {
<form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem"> <form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button> <button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
</form> </form>
</>)}
</div> </div>
</Layout> </Layout>
); );
@ -3956,13 +3981,10 @@ export function CloudAccountsPage(props: CloudAccountsPageProps) {
</td> </td>
<td> <td>
<form method="post" action={`/admin/cloud-accounts/${a.id}/sync`} style="display:inline"> <form method="post" action={`/admin/cloud-accounts/${a.id}/sync`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">Sync</button> <button type="submit" class="btn btn-sm btn-primary">Sync</button>
</form>
<form method="post" action={`/admin/cloud-accounts/${a.id}/import`} style="display:inline; margin-left:0.25rem">
<button type="submit" class="btn btn-sm btn-primary">Import</button>
</form> </form>
<form method="post" action={`/admin/cloud-accounts/${a.id}/delete`} style="display:inline; margin-left:0.25rem" <form method="post" action={`/admin/cloud-accounts/${a.id}/delete`} style="display:inline; margin-left:0.25rem"
{...{"onsubmit": "return confirm('Delete this cloud account?')"}}> {...{"onsubmit": "return confirm('Delete this cloud account and all its synced cameras?')"}}>
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</td> </td>