mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +00:00
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).
69 lines
2.2 KiB
TypeScript
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;
|
|
}
|