Live view over WebRTC (openpanel L1, server and browser)
A device reporting a `live` port streams its camera to the browser's own
RTCPeerConnection. The flows socket carries the signalling as text, so it
crosses the portal: `live` messages on a person's socket, `signal` on a
device's (start, sdp, candidate, bye, busy). The server routes by session,
holds a device to two viewers, ends a session when either socket closes or
no answer comes within 10 s, and gives each socket an outbox of its own
that ends a session rather than drop a message. TURN credentials are
minted per session in coturn's REST scheme (STUN_URLS, TURN_URLS,
TURN_SECRET), the viewer's with the device's offer; GET /devices/{id}/ice
hands them out too. The tunnel keeps a stream's early frames instead of
dropping them past eight and forgets them when the stream or link goes.
A media tile on a device's camera, and the Devices tab's preview, play
live when tapped, pause frames meanwhile, and fall back to frames with a
short note on busy, failure or no picture within 10 s. coturn is an
optional compose service behind the `turn` profile.
This commit is contained in:
@@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { DevicePublic, FlowSummary, NodeTypeInfo } from "@/client"
|
||||
import { type ReportedPort, reportedPorts } from "@/components/Devices/ports"
|
||||
import {
|
||||
LIVE_PORT,
|
||||
type ReportedPort,
|
||||
reportedPorts,
|
||||
} from "@/components/Devices/ports"
|
||||
import { devicesQueryOptions } from "@/components/Devices/queries"
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -58,7 +62,9 @@ export function CommandPalette({
|
||||
// A shortcut per port a device says it has, "Hallway camera". A device that
|
||||
// has not said is added as a Device node and its ports typed in.
|
||||
const capabilities = (devicesData?.data ?? []).flatMap((device) =>
|
||||
(reportedPorts(device) ?? []).map((port) => ({ device, port })),
|
||||
(reportedPorts(device) ?? [])
|
||||
.filter((port) => port.port !== LIVE_PORT)
|
||||
.map((port) => ({ device, port })),
|
||||
)
|
||||
|
||||
const close = (action: () => void) => {
|
||||
|
||||
@@ -134,6 +134,23 @@ type FlowEvent =
|
||||
names: string[]
|
||||
ts?: number
|
||||
}
|
||||
| LiveSignal
|
||||
|
||||
/**
|
||||
* A live view's signalling, both ways on this socket (`fluksio.flow.live`).
|
||||
* The page sends `start`, `sdp`, `candidate` and `bye`, and hears `sdp` (the
|
||||
* device's offer, with the ICE servers to use), `candidate`, `bye` and `busy`.
|
||||
*/
|
||||
export type LiveSignal = {
|
||||
type: "live"
|
||||
device: string
|
||||
session: string
|
||||
action: "start" | "sdp" | "candidate" | "bye" | "busy"
|
||||
sdp?: string
|
||||
candidate?: string
|
||||
reason?: string
|
||||
ice_servers?: RTCIceServer[]
|
||||
}
|
||||
|
||||
async function socketUrl(): Promise<string> {
|
||||
const base = String(OpenAPI.BASE || window.location.origin)
|
||||
@@ -176,6 +193,42 @@ let client: QueryClient | null = null
|
||||
const authHandlers = new Set<() => void>()
|
||||
/** Floor re-reads still owed, per node, so a recovery can cancel its own. */
|
||||
const healthTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
/** What each live session on this page does with what it hears. */
|
||||
const liveListeners = new Map<string, (message: LiveSignal) => void>()
|
||||
|
||||
/** Send a live view's signalling. False while the socket is not open. */
|
||||
export function sendLive(message: Omit<LiveSignal, "type">): boolean {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return false
|
||||
socket.send(JSON.stringify({ type: "live", ...message }))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Hear one live session's signalling, until the returned function is called.
|
||||
* A socket that drops ends every session on it, so each hears `bye` then.
|
||||
*/
|
||||
export function listenLive(
|
||||
session: string,
|
||||
listener: (message: LiveSignal) => void,
|
||||
): () => void {
|
||||
liveListeners.set(session, listener)
|
||||
return () => {
|
||||
if (liveListeners.get(session) === listener) liveListeners.delete(session)
|
||||
}
|
||||
}
|
||||
|
||||
/** The socket went: so did every live session riding on it. */
|
||||
function endLive() {
|
||||
for (const [session, listener] of [...liveListeners]) {
|
||||
listener({
|
||||
type: "live",
|
||||
device: "",
|
||||
session,
|
||||
action: "bye",
|
||||
reason: "socket",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything a node's health shows up in, which is two unrelated keys. */
|
||||
function invalidateHealth(flow: string | undefined) {
|
||||
@@ -436,6 +489,9 @@ function open(url: string) {
|
||||
// live value, so nothing here goes through the live store.
|
||||
client?.invalidateQueries({ queryKey: runKeys.all })
|
||||
break
|
||||
case "live":
|
||||
liveListeners.get(message.session)?.(message)
|
||||
break
|
||||
case "run_metric":
|
||||
// A batch of readings was written. Only what draws them is refetched —
|
||||
// the run's own detail (its metric names and curve hang below that
|
||||
@@ -480,6 +536,7 @@ function open(url: string) {
|
||||
socket = null
|
||||
liveStore.setConnected(false)
|
||||
connectionStore.setSocketOpen(false)
|
||||
endLive()
|
||||
if (watchers === 0) return
|
||||
if (event.code === 1008 && refused()) return
|
||||
// 1013 is the portal saying the instance is not attached — the one
|
||||
@@ -504,6 +561,7 @@ function release(onAuthFailure?: () => void) {
|
||||
const ws = socket
|
||||
socket = null
|
||||
ws?.close()
|
||||
endLive()
|
||||
liveStore.setConnected(false)
|
||||
// Its `onclose` is dropped by the guard above, so say so here instead.
|
||||
connectionStore.setSocketOpen(false)
|
||||
|
||||
Reference in New Issue
Block a user