Cameras on devices (openpanel C1): frames in memory, a binary uplink, demand

- Volatile frames are held in memory with a byte-bounded queue instead of
  the /dev/shm ring scanned on every put; workers keep writing to the
  directory, which is trimmed on a timer. A volatile upload no longer spools
  to disk. ARTIFACT_VOLATILE_BYTES=0 turns the ring off.
- A device sends camera frames as binary messages on its socket, held and
  published in one step and credited to its device node; refused frames are
  logged and dropped. Its snapshot carries demand and the uplink (binary, or
  http through the portal).
- One demand registry for every socket's wanted media, the tunnel's
  included, plus what running flows read, mapped onto on-demand ports and
  published as device_demand; at most 8 media names per socket.
- Per-message deflate off in serve and dev; a device may fetch the last 8
  digests of a message; published dashboards cached per store revision; a
  revoked device socket is closed with 1008.
- Media tiles want bytes only while on screen; the Devices tab previews a
  wired camera; the device node warns when a flow reads an on-demand port.
This commit is contained in:
Melvin Strobl
2026-10-16 20:43:14 +02:00
parent d79203a5f7
commit ecc4bee4bb
29 changed files with 1425 additions and 220 deletions
@@ -19,6 +19,7 @@ import {
FlowsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MediaView } from "@/components/Dashboard/MediaWidget"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { flowsQueryOptions } from "@/components/Flow/queries"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
@@ -139,6 +140,7 @@ export function DeviceDetail({ id }: { id: string }) {
<section className={cn(CARD, "grid gap-4")}>
<Basics device={device} canEdit={canEdit} onChange={write} />
</section>
<Camera device={device} />
<section className={CARD}>
<Dashboards device={device} canEdit={canEdit} onChange={write} />
</section>
@@ -216,6 +218,38 @@ function Basics({
)
}
/**
* What the device's camera sees, once a flow wires its `camera` port.
*
* Watching is what turns the camera on: it sends frames only while this
* preview, a tile or a flow reading them wants them.
*/
function Camera({ device }: { device: DevicePublic }) {
const bound = (device.bindings ?? []).find(
(binding) => binding.port === "camera" && binding.direction === "in",
)
if (!bound) return null
return (
<section className={CARD}>
<PanelSection
title="Camera"
help="Live while this preview is on screen. The camera sends frames only while someone watches them here or on a dashboard, or a running flow reads them."
>
<div
className="flex aspect-video w-full overflow-hidden rounded-md bg-muted text-center text-sm"
data-testid="device-camera"
>
<MediaView
message={bound.message}
fit="contain"
label={`${device.title || device.id} camera`}
/>
</div>
</PanelSection>
</section>
)
}
/** Which dashboards the device shows, in the order its rail carries them. */
function Dashboards({
device,