Real Hikvision/Dahua camera driver; gate Backend-push-IP on capability

Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI
snapshots over client-side HTTP Digest (new drivers/http-digest.ts).
healthCheck() now pulls a real frame instead of returning ready/stub.
Snapshot carries bytes (driver fetches); storage/imageRef is the caller's
job, keeping the adapter free of storage deps.

Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver
(only Dingtian sets it), expose as pushCapable in the catalog, and gate the
wizard's backend-IP fetch + field on it so pull-only devices hide it.

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
This commit is contained in:
2026-06-15 16:17:49 +02:00
parent 59bfe2013f
commit fa65b2df86
10 changed files with 343 additions and 34 deletions
+4 -2
View File
@@ -62,11 +62,13 @@ export async function setupRoutes(
const adminGuard = requireRole("admin");
// Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN.
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
// drivers that push to the backend (and thus need a backend IP at assign time).
app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
return { ...catalog, discoverable };
const pushCapable = registry.pushCapable();
return { ...catalog, discoverable, pushCapable };
});
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
+15 -4
View File
@@ -78,6 +78,7 @@ export function SetupWizard() {
noun={noun}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
onChanged={reloadState}
/>
@@ -93,6 +94,7 @@ function CategorySection({
noun,
entries,
discoverableIds,
pushCapableIds,
assignments,
onChanged,
}: {
@@ -102,6 +104,7 @@ function CategorySection({
noun: string;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
assignments: Assignment[];
onChanged: () => Promise<void> | void;
}) {
@@ -155,6 +158,7 @@ function CategorySection({
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
@@ -227,6 +231,7 @@ function DeviceForm({
category,
entries,
discoverableIds,
pushCapableIds,
onSaved,
onCancel,
}: {
@@ -234,12 +239,17 @@ function DeviceForm({
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id);
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
// must NOT show the field. See wiki/concepts/device-input-flow.md.
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
// Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({});
@@ -255,15 +265,16 @@ function DeviceForm({
// Backend push IP: which of OUR addresses the device should call back on. We
// auto-pick the NIC on the device's subnet, but surface it editable here so a
// multi-NIC host can be corrected (the chosen IP is baked into the device on
// save). Only relevant for drivers that push (the field hides if no candidates).
// save). Only relevant for drivers that push back to us (pushesToBackend).
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>("");
// (Re)load backend-IP candidates whenever the device host changes after a
// successful test (the test confirms the host is real + reachable).
// successful test (the test confirms the host is real + reachable) — but only
// for push-capable drivers; a pull-only device never calls back.
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
useEffect(() => {
if (!testedHost) {
if (!testedHost || !pushesToBackend) {
setBackendIps(null);
return;
}
@@ -281,7 +292,7 @@ function DeviceForm({
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost]);
}, [testedHost, pushesToBackend]);
function selectDriver(id: string) {
setSelectedId(id);
+2
View File
@@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */
discoverable: string[];
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable: string[];
};
export function fetchCatalog(): Promise<Catalog> {