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
+40 -6
View File
@@ -141,25 +141,59 @@ def test_a_ring_drops_the_oldest_once_it_is_full(tmp_path):
ring = VolatileStore(tmp_path / "ring", limit_bytes=3000)
refs = [ring.put([bytes([i]) * 1000], name=f"{i}.bin") for i in range(5)]
held = [ref for ref in refs if ring.path(ref["digest"]) is not None]
held = [ref for ref in refs if ring.has(ref["digest"])]
assert len(held) == 3
# The newest three, in order: eviction is by age, not by chance.
assert held == refs[2:]
assert ring.size == 3000
# Held in memory: nothing reached the directory.
assert not any(ring.root.iterdir())
def test_the_newest_frame_stays_whatever_its_size(tmp_path):
"""A frame larger than the bound is still there for its own publish."""
ring = VolatileStore(tmp_path / "ring", limit_bytes=100)
small = ring.put([b"s" * 50])
big = ring.put([b"b" * 500])
assert ring.get(big["digest"]) == b"b" * 500
assert not ring.has(small["digest"])
def test_a_workers_frame_is_read_from_its_directory_once(tmp_path):
"""A worker in the container writes its frames to the ring's directory."""
import hashlib
ring = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
data = b"a worker's frame"
body = hashlib.sha256(data).hexdigest()
path = ring.root / body[:2] / body
path.parent.mkdir(parents=True)
path.write_bytes(data)
assert ring.has("sha256:" + body)
assert ring.get("sha256:" + body) == data
# Held after the first read, so every screen is served from memory.
path.unlink()
assert ring.get("sha256:" + body) == data
def test_a_volatile_reference_resolves_like_any_other(tmp_path):
"""A frame is an ordinary reference, which is what keeps the rest honest.
Everything that resolves a digest — serving one, checking a run's input,
a panel's scope — goes through ``path``, so the ring has to answer there.
Everything that asks whether a digest resolves — checking a run's input,
a cached result — goes through ``has``, and reading one through ``read``,
so the ring has to answer there.
"""
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
ref = store.put([b"frame"], name="f.png", media_type="image/png", volatile=True)
assert store.path(ref["digest"]) is not None
assert store.has(ref["digest"])
assert b"".join(store.read(ref["digest"])) == b"frame"
# In the ring, and not on the volume the sweep is about.
assert store.path(ref["digest"]) is None
assert not (store.root / ref["digest"][7:9]).exists()
@@ -171,7 +205,7 @@ def test_a_recorded_frame_is_copied_out_of_the_ring(tmp_path):
assert store.adopt(ref["digest"]) is True
store.volatile.collect(set())
store.volatile = VolatileStore(tmp_path / "ring2", limit_bytes=1_000_000)
assert store.path(ref["digest"]) is not None
assert store.adopt("sha256:" + "0" * 64) is False
@@ -195,7 +229,7 @@ def test_an_upload_may_ask_for_the_ring(
assert answer.status_code == 200, answer.text
digest = answer.json()["digest"]
assert store.volatile.path(digest) is not None
assert store.volatile.has(digest)
assert not (store.root / digest[7:9]).exists()
# And it is served back like anything else, which is what the widget
# falls back on when the socket did not push it.