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_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:

View file

@ -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<Record<string, string>>(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<Record<string, string>>(event);
let rtspUrl: string | null = null;
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 { 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 {
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) => {
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" });
await syncCloudAccount(id, deps);
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) => {

View file

@ -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<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_sink: s(r["event_sink"] ?? "auto"),
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 (
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,

View file

@ -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)`,
];

View file

@ -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<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[]> {
const rs = await this._all(
"SELECT * FROM camera_streams WHERE camera_id = ?",

View file

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

View file

@ -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),
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, // Hik-Connect doesn't expose RTSP URLs — local ONVIF needed
relay_url: null,
rtsp_url: null,
relay_url: streamUrl,
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 {
return [];
}
}
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
// 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".
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<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;
}
}
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 "https://api.hik-connect.com"; // EU is default
return API_BASE;
}
private async login(creds: Record<string, string>): Promise<string | null> {
@ -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;

View file

@ -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: {},
}];
}

View file

@ -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<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)",
};
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<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}`,
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: {},
});
}

View file

@ -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 {

View file

@ -1245,6 +1245,30 @@ export function CameraEditPage(props: CameraEditProps) {
}
>
<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">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Camera</h2>
<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">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
</form>
</>)}
</div>
</Layout>
);
@ -3956,13 +3981,10 @@ export function CloudAccountsPage(props: CloudAccountsPageProps) {
</td>
<td>
<form method="post" action={`/admin/cloud-accounts/${a.id}/sync`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">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>
<button type="submit" class="btn btn-sm btn-primary">Sync</button>
</form>
<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>
</form>
</td>