BetterFrame/server/src/shared/kiosk-lan.ts
Mitchell R 334ee8fb93
feat(preview): pull entity snapshot from active kiosk first
When admin opens an entity preview, find a kiosk whose active layout
references the camera (new repo.listKiosksRenderingCamera). Probe each
candidate's LAN snapshot endpoint with a 4s timeout. On success, stream
the bytes back with x-bf-snapshot-source: kiosk:<id>. Falls through to
the existing server-direct ffmpeg/gst pull only when no kiosk is reachable
or has the camera in its active layout.

Kiosk side adds /local/snapshot/:camera_id?key=<local_key>. Spawns a
one-shot gst-launch (rtspsrc → decodebin → jpegenc ! filesink
num-buffers=1) on a blocking worker so axum's reactor stays free.
Prefers sub stream for snapshots to keep bandwidth low. Single-frame
pipeline tears down after the first JPEG.

LAN IP picking extracted to shared/kiosk-lan.ts so route handler and
KioskLocalPanel agree on which interface to talk to (the previously-
duplicated logic in admin-pages stays for now since it also renders the
interface list).

Why a parallel pipeline instead of teeing the warm one: cross-thread
gtk4paintablesink → appsink sample extraction is non-trivial. A 1-frame
parallel pull is cheap when the kiosk's RTSP session to that camera is
already known to work (precondition: it's in the active layout).
2026-05-21 10:35:27 +02:00

69 lines
2.2 KiB
TypeScript

/**
* Pick the kiosk's preferred LAN IP for direct HTTP reach.
*
* Behind Docker/Angie the heartbeat source IP we see (kiosk.local_last_ip)
* is the proxy/container bridge (e.g. 172.31.0.2), not the kiosk's real LAN
* address. Kiosks report all their interfaces via the heartbeat
* (network_interfaces_json from `ip -j addr show`). Prefer the first
* non-loopback / non-link-local IP from that list; fall back to the
* heartbeat source only when we have nothing reported.
*/
import type { Kiosk } from "./types.js";
interface ReportedInterface {
name: string;
mac?: string | null;
operstate?: string | null;
ips: string[];
}
function ipWithoutCidr(ip: string): string {
return ip.includes("/") ? ip.slice(0, ip.indexOf("/")) : ip;
}
function isUsableLanIp(ip: string): boolean {
const bare = ipWithoutCidr(ip);
return bare !== "::1"
&& !bare.startsWith("127.")
&& !bare.startsWith("169.254.")
&& !bare.startsWith("fe80:");
}
function parseInterfaces(raw: string | null): ReportedInterface[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((item) => ({
name: typeof item?.name === "string" ? item.name : "unknown",
operstate: typeof item?.operstate === "string" ? item.operstate : null,
ips: Array.isArray(item?.ips)
? item.ips.filter((ip: unknown) => typeof ip === "string")
: [],
}))
.filter((item) => item.ips.length > 0);
} catch {
return [];
}
}
/** Returns a bare IP (no CIDR) suitable for `http://<ip>:<port>/...`. */
export function pickKioskLanIp(kiosk: Kiosk): string | null {
const ifaces = parseInterfaces(kiosk.network_interfaces_json);
// Prefer interfaces marked UP, then any with usable IPs.
const sorted = [...ifaces].sort((a, b) => {
const aUp = a.operstate?.toLowerCase() === "up" ? 0 : 1;
const bUp = b.operstate?.toLowerCase() === "up" ? 0 : 1;
return aUp - bUp;
});
for (const iface of sorted) {
for (const ip of iface.ips) {
if (isUsableLanIp(ip)) return ipWithoutCidr(ip);
}
}
if (kiosk.local_last_ip && isUsableLanIp(kiosk.local_last_ip)) {
return ipWithoutCidr(kiosk.local_last_ip);
}
return null;
}