mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
827ed39514
commit
a9484d1dd7
16 changed files with 335 additions and 122 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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 = ?",
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue