From 0f0971d8df264bd9697c69e2ae1963133ad76724 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 03:13:21 -0700 Subject: [PATCH 01/45] feat(adapters): add OakCameraStream reference adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Luxonis OAK camera adapter using the DepthAI v3 pipeline API. Captures RGB to an MP4 via cv2.VideoWriter and optionally streams raw uint16 depth to a sibling {id}.depth.bin file. Design ------ Intentionally thinner than the full-featured OakCamera used inside opengraph-studio/recorder — ships the 80% common case (RGB + optional depth) so the code stays small, testable, and easy to extend. Users who need IMU, stereo rectified outputs, or custom calibration can subclass directly against the depthai API. Scope ----- - OakCameraStream(StreamBase) with: * RGB via Camera → requestOutput → OutputQueue * Optional depth via StereoDepth node (HIGH_DETAIL preset) * Background capture thread — read/timestamp/write/emit in a tight loop * Depth pulled with tryGet() on the same tick as RGB so they share the same monotonic anchor * Declares produces_file=True, is_removable=True, supports_precise_timestamps=True, provides_audio_track=False - iter_depth_frames() helper for consumers that want to read back the raw .depth.bin file later - Gated behind the new syncfield[oak] extra (depthai>=3.0.0). The adapter also uses opencv-python for the MP4 writer, so install with syncfield[oak,uvc] or syncfield[all] - adapters/__init__.py lazy-exports OakCameraStream so an uninstalled extra does not break `import syncfield.adapters` Tests ----- tests/unit/adapters/test_oak_camera.py (8 tests) with a mocked depthai module that models the v3 pipeline + queue API. Covers: - Capability flags round-trip - prepare() builds + starts the pipeline - prepare() raises cleanly when no OAK devices are connected - start/stop lifecycle produces a file_path in the FinalizationReport - stop() releases the pipeline - depth_enabled=True creates a second pipeline node (StereoDepth) - depth_enabled=False is the default and creates only the RGB camera - Missing depthai raises ImportError with `syncfield[oak]` install hint Full suite: 138 passing (was 130). Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 5 + src/syncfield/adapters/__init__.py | 21 +- src/syncfield/adapters/oak_camera.py | 359 +++++++++++++++++++++++++ tests/unit/adapters/test_oak_camera.py | 198 ++++++++++++++ uv.lock | 25 +- 5 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 src/syncfield/adapters/oak_camera.py create mode 100644 tests/unit/adapters/test_oak_camera.py diff --git a/pyproject.toml b/pyproject.toml index 02e6bac..84e4438 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,11 +32,16 @@ audio = [ ] uvc = ["opencv-python>=4.5"] ble = ["bleak>=0.21"] +# OakCameraStream uses depthai v3 for the pipeline and reuses the `uvc` +# extra's opencv-python for the MP4 writer. Install `syncfield[oak,uvc]` +# (or `syncfield[all]`) for the full OAK capture path. +oak = ["depthai>=3.0.0"] all = [ "sounddevice>=0.4.6", "numpy>=1.21", "opencv-python>=4.5", "bleak>=0.21", + "depthai>=3.0.0", ] [project.urls] diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py index c46b353..d46ea7d 100644 --- a/src/syncfield/adapters/__init__.py +++ b/src/syncfield/adapters/__init__.py @@ -5,13 +5,14 @@ corresponding extra is not installed, importing ``syncfield.adapters`` still succeeds but that specific class is simply absent from the module. -========================= ==================================== ===================== -Adapter Requires Install -========================= ==================================== ===================== -``JSONLFileStream`` — ``syncfield`` -``UVCWebcamStream`` ``opencv-python`` ``syncfield[uvc]`` -``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]`` -========================= ==================================== ===================== +========================= ===================================== ============================= +Adapter Requires Install +========================= ===================================== ============================= +``JSONLFileStream`` — ``syncfield`` +``UVCWebcamStream`` ``opencv-python`` ``syncfield[uvc]`` +``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]`` +``OakCameraStream`` ``depthai`` + ``opencv-python`` ``syncfield[oak,uvc]`` +========================= ===================================== ============================= Users who need a specific optional adapter can always import it directly (e.g. ``from syncfield.adapters.uvc_webcam import UVCWebcamStream``) — that @@ -39,3 +40,9 @@ __all__.append("BLEImuGenericStream") except ImportError: pass + +try: + from syncfield.adapters.oak_camera import OakCameraStream # noqa: F401 + __all__.append("OakCameraStream") +except ImportError: + pass diff --git a/src/syncfield/adapters/oak_camera.py b/src/syncfield/adapters/oak_camera.py new file mode 100644 index 0000000..a4547c4 --- /dev/null +++ b/src/syncfield/adapters/oak_camera.py @@ -0,0 +1,359 @@ +"""OakCameraStream — DepthAI-based reference adapter for Luxonis OAK cameras. + +Supports OAK-1, OAK-D, OAK-D Lite, OAK-D S2 and related devices through the +DepthAI v3 pipeline API. The adapter captures RGB frames to an MP4 file via +``cv2.VideoWriter`` and, when ``depth_enabled=True``, also writes a raw uint16 +depth stream (little-endian, millimeters) to a sibling ``.depth.bin`` file. + +Requires two optional extras: + + pip install syncfield[oak] # depthai + pip install syncfield[uvc] # opencv-python for the MP4 writer + +Both extras are available together via ``syncfield[all]``. + +The adapter is intentionally thinner than the full-featured OakCamera class +used inside opengraph-studio/recorder — it ships the 80% common case (RGB + +optional depth) so the code stays small and easy to extend. For IMU, stereo +rectified output, or advanced calibration, write a subclass against the +depthai API directly. +""" + +from __future__ import annotations + +import struct +import threading +import time +from pathlib import Path +from typing import Any, Optional, Tuple + +try: + import depthai as dai # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - exercised via sys.modules patch + raise ImportError( + "OakCameraStream requires depthai. " + "Install with `pip install syncfield[oak]`." + ) from exc + +try: + import cv2 # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - exercised via sys.modules patch + raise ImportError( + "OakCameraStream also requires opencv-python for the MP4 writer. " + "Install with `pip install syncfield[uvc]`." + ) from exc + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + SampleEvent, + StreamCapabilities, +) + + +class OakCameraStream(StreamBase): + """Captures RGB (and optional depth) from a Luxonis OAK camera. + + Lifecycle: + 1. ``prepare()`` discovers a device, builds a DepthAI pipeline with an + RGB ``Camera`` node (and optionally a ``StereoDepth`` node), and + starts the pipeline. + 2. ``start()`` opens the MP4 writer (and depth raw-bin file if + depth is enabled), then spins up a background thread that reads + frames in a tight loop, timestamps each read with + ``time.monotonic_ns()``, writes the frame to disk, and emits a + :class:`~syncfield.types.SampleEvent`. + 3. ``stop()`` signals the thread, joins it, releases the pipeline + and writers, and returns a :class:`FinalizationReport`. + + Args: + id: Stream id (also used as the output file name: ``{id}.mp4``). + output_dir: Directory for the MP4 (and optional depth) file. + rgb_resolution: Desired RGB resolution as ``(width, height)``. + rgb_fps: Desired RGB frame rate. + depth_enabled: If True, also capture raw depth to ``{id}.depth.bin``. + depth_resolution: Depth resolution as ``(width, height)``. Must be a + resolution supported by the device (e.g. ``(640, 400)``). + depth_fps: Depth frame rate. + """ + + def __init__( + self, + id: str, + output_dir: Path | str, + rgb_resolution: Tuple[int, int] = (1920, 1080), + rgb_fps: int = 30, + depth_enabled: bool = False, + depth_resolution: Tuple[int, int] = (640, 400), + depth_fps: int = 30, + ) -> None: + super().__init__( + id=id, + kind="video", + capabilities=StreamCapabilities( + provides_audio_track=False, # OAK cameras have no audio + supports_precise_timestamps=True, + is_removable=True, + produces_file=True, + ), + ) + self._output_dir = Path(output_dir) + self._rgb_resolution = rgb_resolution + self._rgb_fps = rgb_fps + self._depth_enabled = depth_enabled + self._depth_resolution = depth_resolution + self._depth_fps = depth_fps + + # Pipeline + queue handles (populated in prepare()). + self._pipeline: Any = None + self._q_rgb: Any = None + self._q_depth: Any = None + + # Recording state. + self._mp4_path = self._output_dir / f"{id}.mp4" + self._depth_path = self._output_dir / f"{id}.depth.bin" + self._video_writer: Any = None + self._depth_file: Any = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._frame_count = 0 + self._depth_frame_count = 0 + self._first_at: Optional[int] = None + self._last_at: Optional[int] = None + + # ------------------------------------------------------------------ + # Stream SPI + # ------------------------------------------------------------------ + + def prepare(self) -> None: + """Discover a device and build the DepthAI pipeline. + + Raises: + RuntimeError: If no OAK devices are connected. + """ + self._output_dir.mkdir(parents=True, exist_ok=True) + + devices = dai.Device.getAllAvailableDevices() + if not devices: + raise RuntimeError("No OAK devices found") + + self._pipeline = self._build_pipeline() + self._pipeline.build() + self._pipeline.start() + + # Short warmup — the first few frames are often None while the + # camera settles. Keeps the capture loop's error counters clean. + time.sleep(1.0) + + def start(self, session_clock: SessionClock) -> None: + """Open output files and launch the background capture thread.""" + width, height = self._rgb_resolution + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + self._video_writer = cv2.VideoWriter( + str(self._mp4_path), fourcc, float(self._rgb_fps), (width, height) + ) + if self._depth_enabled: + self._depth_file = open(self._depth_path, "wb") + + self._stop_event.clear() + self._thread = threading.Thread( + target=self._capture_loop, name=f"oak-{self.id}", daemon=True + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + """Signal the thread, release the pipeline, return the report.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=3.0) + + self._release_writers() + self._release_pipeline() + + extra_channels: dict[str, Any] = {} + if self._depth_enabled: + extra_channels["depth_frame_count"] = self._depth_frame_count + extra_channels["depth_path"] = ( + str(self._depth_path) if self._depth_frame_count > 0 else None + ) + + report = FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=self._mp4_path if self._frame_count > 0 else None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + # Expose depth stats through the health_events buffer so consumers + # that only look at FinalizationReport still get visibility. + return report + + # ------------------------------------------------------------------ + # Pipeline construction + # ------------------------------------------------------------------ + + def _build_pipeline(self) -> Any: + """Build a DepthAI v3 pipeline with the requested outputs. + + Always creates an RGB ``Camera`` node. If ``depth_enabled`` is + True, also creates a ``StereoDepth`` node wired to the on-board + mono cameras. + """ + pipeline = dai.Pipeline() + + # --- RGB camera -------------------------------------------------- + cam = pipeline.create(dai.node.Camera) + cam.build() + rgb_out = cam.requestOutput( + self._rgb_resolution, + dai.ImgFrame.Type.BGR888p, + fps=float(self._rgb_fps), + ) + self._q_rgb = rgb_out.createOutputQueue() + + # --- Optional stereo depth -------------------------------------- + if self._depth_enabled: + stereo = pipeline.create(dai.node.StereoDepth) + stereo.build( + autoCreateCameras=True, + presetMode=dai.node.StereoDepth.PresetMode.HIGH_DETAIL, + size=self._depth_resolution, + fps=float(self._depth_fps), + ) + self._q_depth = stereo.depth.createOutputQueue() + + return pipeline + + # ------------------------------------------------------------------ + # Capture loop + # ------------------------------------------------------------------ + + def _capture_loop(self) -> None: + """Body of the background thread — tight read/timestamp/write loop. + + The timestamp is captured *immediately* after ``queue.get()`` so + the jitter between the physical frame and the recorded timestamp + stays as small as possible. Depth frames are consumed in the + same tick with ``tryGet()`` so depth and RGB share the same + monotonic anchor. + """ + while not self._stop_event.is_set(): + rgb_msg = self._safe_get_rgb() + capture_ns = time.monotonic_ns() + if rgb_msg is None: + continue + + frame = rgb_msg.getCvFrame() + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + + if self._video_writer is not None: + self._video_writer.write(frame) + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=self._frame_count - 1, + capture_ns=capture_ns, + ) + ) + + if self._depth_enabled: + self._drain_depth_tick() + + def _safe_get_rgb(self) -> Any: + """Pull one RGB frame from the queue, swallowing timeouts.""" + try: + return self._q_rgb.get(timeout=0.1) + except Exception: + return None + + def _drain_depth_tick(self) -> None: + """Non-blocking depth pull — write whatever is ready this tick.""" + if self._q_depth is None or self._depth_file is None: + return + depth_msg = self._q_depth.tryGet() + if depth_msg is None: + return + try: + depth_frame = depth_msg.getFrame() # uint16, little-endian, mm + self._depth_file.write(depth_frame.tobytes()) + self._depth_frame_count += 1 + except Exception: + # Depth is best-effort — a transient failure should not tear + # down the RGB capture loop. + pass + + # ------------------------------------------------------------------ + # Resource cleanup + # ------------------------------------------------------------------ + + def _release_writers(self) -> None: + if self._video_writer is not None: + try: + self._video_writer.release() + except Exception: + pass + self._video_writer = None + if self._depth_file is not None: + try: + self._depth_file.flush() + self._depth_file.close() + except Exception: + pass + self._depth_file = None + + def _release_pipeline(self) -> None: + if self._pipeline is not None: + try: + self._pipeline.stop() + except Exception: + pass + self._pipeline = None + self._q_rgb = None + self._q_depth = None + + +# --------------------------------------------------------------------------- +# Depth binary format helper +# --------------------------------------------------------------------------- +# +# The ``.depth.bin`` file is a simple concatenation of raw uint16 depth frames +# in row-major order. No header. Consumers need the resolution (which they +# can read from the manifest or know out of band) to reshape the buffer. +# This helper is purely informational — adapters do not need to call it. + + +def iter_depth_frames( + path: Path | str, + width: int, + height: int, +): # pragma: no cover - convenience helper, not exercised by adapter tests + """Yield successive depth frames from a raw ``.depth.bin`` file. + + Args: + path: Path to the ``.depth.bin`` file produced by OakCameraStream. + width: Depth width in pixels (as configured on the stream). + height: Depth height in pixels. + + Yields: + Tuples of ``(frame_index, flat_uint16_list)``. Callers that want + numpy arrays can do ``np.asarray(values, dtype=np.uint16).reshape( + height, width)``. + """ + frame_bytes = width * height * 2 + fmt = f"<{width * height}H" + path = Path(path) + with path.open("rb") as f: + idx = 0 + while True: + chunk = f.read(frame_bytes) + if len(chunk) < frame_bytes: + return + yield idx, list(struct.unpack(fmt, chunk)) + idx += 1 diff --git a/tests/unit/adapters/test_oak_camera.py b/tests/unit/adapters/test_oak_camera.py new file mode 100644 index 0000000..791ecd5 --- /dev/null +++ b/tests/unit/adapters/test_oak_camera.py @@ -0,0 +1,198 @@ +"""Unit tests for OakCameraStream using a mocked depthai module.""" + +from __future__ import annotations + +import importlib +import sys +import time +from unittest.mock import MagicMock + +import pytest + +from syncfield.clock import SessionClock +from syncfield.types import SyncPoint + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def _build_fake_depthai(frame_budget: int = 3) -> MagicMock: + """Return a MagicMock that looks enough like depthai for the adapter. + + Models the depthai v3 pipeline API: Pipeline.build() / start() / stop(), + Camera node with requestOutput() → OutputQueue.get() → ImgFrame-like + object exposing .getCvFrame() and .getTimestamp(). + """ + fake = MagicMock() + + # --- Fake frame object with numpy-shaped data ------------------------ + class _FakeFrame: + def __init__(self) -> None: + self._cv_frame = MagicMock() + self._cv_frame.shape = (1080, 1920, 3) + + def getCvFrame(self) -> MagicMock: + return self._cv_frame + + # --- Fake output queue: returns a few frames then None -------------- + call_count = {"n": 0} + + def make_queue() -> MagicMock: + q = MagicMock() + + def fake_get(timeout: float = 0.1) -> _FakeFrame | None: + call_count["n"] += 1 + if call_count["n"] <= frame_budget: + return _FakeFrame() + return None + + q.get.side_effect = fake_get + q.tryGet.return_value = None + return q + + rgb_queue = make_queue() + + # --- Fake Camera node ----------------------------------------------- + camera_node = MagicMock() + camera_node.requestOutput.return_value.createOutputQueue.return_value = rgb_queue + + # --- Fake pipeline: pipeline.create(dai.node.Camera) returns camera - + pipeline = MagicMock() + pipeline.create.return_value = camera_node + pipeline.getDefaultDevice.return_value.getUsbSpeed.return_value = MagicMock( + name="SUPER", value=3 + ) + + fake.Pipeline.return_value = pipeline + + # dai.node namespace + fake.node = MagicMock() + fake.node.Camera = object # sentinel class passed to pipeline.create + + # dai.Device.getAllAvailableDevices() + fake.Device.getAllAvailableDevices.return_value = [MagicMock()] + + # dai.ImgFrame.Type.BGR888p sentinel + fake.ImgFrame.Type.BGR888p = "BGR888p" + + # dai.UsbSpeed.SUPER sentinel (for USB-speed warning branch) + fake.UsbSpeed.SUPER = MagicMock(value=3) + + return fake + + +@pytest.fixture +def mock_depthai(monkeypatch): + fake = _build_fake_depthai() + monkeypatch.setitem(sys.modules, "depthai", fake) + # Also mock cv2 since the adapter uses it for the VideoWriter + fake_cv2 = MagicMock() + fake_cv2.VideoWriter_fourcc = lambda *args: 0 + fake_cv2.VideoWriter.return_value = MagicMock() + monkeypatch.setitem(sys.modules, "cv2", fake_cv2) + # Force re-import so the adapter binds to the fake modules + sys.modules.pop("syncfield.adapters.oak_camera", None) + importlib.import_module("syncfield.adapters.oak_camera") + yield fake, fake_cv2 + sys.modules.pop("syncfield.adapters.oak_camera", None) + + +class TestCapabilities: + def test_capabilities(self, mock_depthai, tmp_path): + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + assert stream.capabilities.produces_file is True + assert stream.capabilities.provides_audio_track is False + assert stream.capabilities.is_removable is True + assert stream.capabilities.supports_precise_timestamps is True + assert stream.kind == "video" + + +class TestLifecycle: + def test_prepare_builds_and_starts_pipeline(self, mock_depthai, tmp_path): + fake, _ = mock_depthai + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + stream.prepare() + + # Pipeline was constructed, built, and started + fake.Pipeline.assert_called_once() + pipeline = fake.Pipeline.return_value + assert pipeline.build.called + assert pipeline.start.called + + def test_prepare_raises_when_no_devices(self, mock_depthai, tmp_path): + fake, _ = mock_depthai + fake.Device.getAllAvailableDevices.return_value = [] + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + with pytest.raises(RuntimeError, match="No OAK devices"): + stream.prepare() + + def test_start_stop_produces_file_path(self, mock_depthai, tmp_path): + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + stream.prepare() + stream.start(_clock()) + # Give the background thread time to read the mocked frames + time.sleep(0.15) + report = stream.stop() + + assert report.status == "completed" + assert report.file_path is not None + assert report.frame_count >= 1 + + def test_stop_releases_pipeline(self, mock_depthai, tmp_path): + fake, _ = mock_depthai + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + stream.prepare() + stream.start(_clock()) + time.sleep(0.05) + stream.stop() + + pipeline = fake.Pipeline.return_value + assert pipeline.stop.called + + +class TestDepthOption: + def test_depth_enabled_declares_depth_output(self, mock_depthai, tmp_path): + """When depth_enabled=True the pipeline builds a StereoDepth node.""" + fake, _ = mock_depthai + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream( + "oak_d", + output_dir=tmp_path, + depth_enabled=True, + ) + stream.prepare() + + # pipeline.create was called twice (Camera + StereoDepth) + pipeline = fake.Pipeline.return_value + assert pipeline.create.call_count >= 2 + + def test_depth_disabled_by_default(self, mock_depthai, tmp_path): + """Default config builds only the RGB camera node.""" + fake, _ = mock_depthai + from syncfield.adapters.oak_camera import OakCameraStream + + stream = OakCameraStream("oak", output_dir=tmp_path) + stream.prepare() + + pipeline = fake.Pipeline.return_value + assert pipeline.create.call_count == 1 # RGB only + + +class TestImportGuard: + def test_depthai_missing_raises_clear_install_hint(self, monkeypatch): + monkeypatch.setitem(sys.modules, "depthai", None) + sys.modules.pop("syncfield.adapters.oak_camera", None) + with pytest.raises(ImportError, match=r"syncfield\[oak\]"): + importlib.import_module("syncfield.adapters.oak_camera") diff --git a/uv.lock b/uv.lock index ee625cd..eef2071 100644 --- a/uv.lock +++ b/uv.lock @@ -259,6 +259,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/0e/1f818f5dad75b806e1e65586e5380bec64565caf7caeee7047dfd5ff8c3d/dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:92b3aaea0e6df4cf83208ae994b08554335166eff726947733b93da748eab641", size = 886837, upload-time = "2026-04-02T04:50:45.644Z" }, ] +[[package]] +name = "depthai" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1c/3b14307c91562991ff4c71e0df6c4bb63c9675ce8d842165b428606ec4e7/depthai-3.5.0-cp39.cp310.cp311.cp312.cp313.cp314-cp39.cp310.cp311.cp312.cp313.cp314-macosx_11_0_arm64.whl", hash = "sha256:57016fdd49075d3b555d62ffde16ccf1b1d6db6e8badf97cd5f9044c4a520297", size = 62086809, upload-time = "2026-03-18T12:36:32.97Z" }, + { url = "https://files.pythonhosted.org/packages/27/36/17c550a9df22bf7d58c7498129f238513a0824aa1b9352753ede260257b2/depthai-3.5.0-cp39.cp310.cp311.cp312.cp313.cp314-cp39.cp310.cp311.cp312.cp313.cp314-macosx_11_0_x86_64.whl", hash = "sha256:ede7561a2d9c759405dd93cdb6f51c36e5a3e06589019f9bd5d9e2907cbadd26", size = 65599818, upload-time = "2026-03-18T12:36:37.402Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3e/f12d272d12300bb31ae4804e4feeb07bf681f221e5e850d6e151c7642695/depthai-3.5.0-cp39.cp310.cp311.cp312.cp313.cp314-cp39.cp310.cp311.cp312.cp313.cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0acfd0343fd363f0b28eff538dc2457a82a8cb30294afd89ce0be1c7da8e9e52", size = 76093150, upload-time = "2026-03-18T12:36:41.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/35/2de5f777a981206cebb08f1a57e2aa13753aaf6931b34d3af55bac708599/depthai-3.5.0-cp39.cp310.cp311.cp312.cp313.cp314-cp39.cp310.cp311.cp312.cp313.cp314-manylinux_2_28_x86_64.whl", hash = "sha256:93ea74a11513930cd7717ec31018a984ba8911fa316b2db10c7c7b455850963a", size = 81013919, upload-time = "2026-03-18T12:36:46.395Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/4aa5df7f44258683b898d4a6b4767fe1e773e6eacf68d7e4e3069c6fa9e0/depthai-3.5.0-cp39.cp310.cp311.cp312.cp313.cp314-cp39.cp310.cp311.cp312.cp313.cp314-win_amd64.whl", hash = "sha256:1a751c5798068db41b1542b8f79695f18f56838b82dbae4538445afa1ba1075a", size = 66519654, upload-time = "2026-03-18T12:36:50.488Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -825,6 +842,7 @@ source = { editable = "." } all = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "depthai" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -841,6 +859,9 @@ ble = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] +oak = [ + { name = "depthai" }, +] uvc = [ { name = "opencv-python" }, ] @@ -856,6 +877,8 @@ dev = [ requires-dist = [ { name = "bleak", marker = "extra == 'all'", specifier = ">=0.21" }, { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.21" }, + { name = "depthai", marker = "extra == 'all'", specifier = ">=3.0.0" }, + { name = "depthai", marker = "extra == 'oak'", specifier = ">=3.0.0" }, { name = "numpy", marker = "extra == 'all'", specifier = ">=1.21" }, { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.21" }, { name = "opencv-python", marker = "extra == 'all'", specifier = ">=4.5" }, @@ -863,7 +886,7 @@ requires-dist = [ { name = "sounddevice", marker = "extra == 'all'", specifier = ">=0.4.6" }, { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, ] -provides-extras = ["audio", "uvc", "ble", "all"] +provides-extras = ["audio", "uvc", "ble", "oak", "all"] [package.metadata.requires-dev] dev = [ From e3210c809bb084a2830617a20a3f96df0a53121c Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 08:52:21 -0700 Subject: [PATCH 02/45] feat(viewer): bundled desktop GUI (MuJoCo-style launch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ``syncfield.viewer``, a DearPyGui-based desktop viewer that renders in-process alongside the SDK — no HTTP, no IPC. Matches the launch API pattern established by ``mujoco.viewer``: import syncfield.viewer syncfield.viewer.launch(session) # blocking with syncfield.viewer.launch_passive(session) as v: # passive ... Design ------ The viewer is strictly additive. The core SDK stays stdlib-only; users who don't want the GUI install nothing new. ``pip install 'syncfield[viewer]'`` pulls in ``dearpygui>=2.0`` and ``numpy>=1.21`` (1.8 MiB wheel total). Architecture is three layers: - **state.py** — immutable SessionSnapshot/StreamSnapshot dataclasses, plus a mutable StreamStatsBuffer the poller maintains per stream (rolling fps window, plot deques with NaN back-fill for channels that appear mid-stream, capped health log). - **poller.py** — daemon background thread polls the session at 10 Hz, subscribes to each stream's on_sample/on_health to catch per-sample data that would otherwise be lost between poll ticks. Publishes immutable snapshots under a single lock. - **app.py + widgets/** — DearPyGui render loop reads the latest snapshot on every frame and fans values out to widgets. All DPG mutation is on the main thread; session.start()/stop() are delegated to a worker thread so the UI never blocks. Widgets ------- - widgets/layout.py — top-level screen composition. Header (host + state chip + elapsed), control panel (Record/Stop/Cancel, buttons wired to session via a worker thread), session clock panel (sync point + chirp timing + tone spec), horizontal stream card row (scrollable), health events table, footer (output path + wall clock). - widgets/stream_card.py — per-stream card with three body variants: video (raw GPU texture updated from stream.latest_frame with letterboxing), sensor/audio (line plot with multi-channel series, calibrated OpenGraph color palette), generic (stats-only fallback). Cards are created lazily when the layout first sees a stream id, so dynamic additions Just Work. - widgets/formatting.py — small pure-Python formatters with unit tests. Theme ----- Light theme only, matching OpenGraph's minimal sophisticated design language. One file (theme.py) owns every token — near-white surfaces, subtle gray borders, near-black primary text, calibrated indigo accent, soft border radii (6-10 px), generous padding. A future dark mode or brand recolor is a single-file change. Button variants (primary/danger/ ghost) and panel variants (card/soft) are built as separate DPG themes and bound at widget creation time. Video frame publishing ---------------------- UVCWebcamStream and OakCameraStream now expose a thread-safe ``latest_frame`` property. The capture loops stash a reference to the most recent BGR frame under a small lock; the viewer reads that reference, resizes nearest-neighbor into the preview texture buffer (numpy-only, no opencv dependency in the viewer), and uploads to the GPU via ``dpg.set_value``. ~10 lines per adapter, zero change to the capture hot path. Tests ----- tests/unit/viewer/ (41 new tests): - test_formatting.py — all format_* helpers including edge cases (negative elapsed, millisecond rounding overflow, NaN/None handling) - test_state.py — StreamStatsBuffer sample observation, fps rolling window with 1-second cutoff, NaN back-fill for late-joining plot channels and forward-fill for missing channels, non-numeric channel filtering, health event ordering - test_poller.py — end-to-end snapshot building against a real SessionOrchestrator with FakeStreams, sync point population after start(), sample/health callback wiring, background thread lifecycle All 179 tests pass (138 existing SDK + 41 new viewer). Demo ---- syncfield/viewer/demo.py ships a runnable synthetic session with two fake video sources (procedural gradient + drift), a sinusoidal IMU, and a plain FakeStream — enough to exercise every card variant without hardware. Intended as both a manual-test harness and the screenshot generator for the docs. ``--auto-record`` starts the session on launch; ``--duration`` auto-closes after N seconds for headless screenshotting. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 8 + src/syncfield/adapters/oak_camera.py | 26 ++ src/syncfield/adapters/uvc_webcam.py | 27 ++ src/syncfield/viewer/__init__.py | 48 +++ src/syncfield/viewer/app.py | 278 ++++++++++++++ src/syncfield/viewer/demo.py | 342 +++++++++++++++++ src/syncfield/viewer/poller.py | 249 ++++++++++++ src/syncfield/viewer/state.py | 212 ++++++++++ src/syncfield/viewer/theme.py | 340 ++++++++++++++++ src/syncfield/viewer/widgets/__init__.py | 7 + src/syncfield/viewer/widgets/formatting.py | 77 ++++ src/syncfield/viewer/widgets/layout.py | 404 ++++++++++++++++++++ src/syncfield/viewer/widgets/stream_card.py | 318 +++++++++++++++ tests/unit/viewer/__init__.py | 0 tests/unit/viewer/test_formatting.py | 117 ++++++ tests/unit/viewer/test_poller.py | 117 ++++++ tests/unit/viewer/test_state.py | 123 ++++++ uv.lock | 36 +- 18 files changed, 2728 insertions(+), 1 deletion(-) create mode 100644 src/syncfield/viewer/__init__.py create mode 100644 src/syncfield/viewer/app.py create mode 100644 src/syncfield/viewer/demo.py create mode 100644 src/syncfield/viewer/poller.py create mode 100644 src/syncfield/viewer/state.py create mode 100644 src/syncfield/viewer/theme.py create mode 100644 src/syncfield/viewer/widgets/__init__.py create mode 100644 src/syncfield/viewer/widgets/formatting.py create mode 100644 src/syncfield/viewer/widgets/layout.py create mode 100644 src/syncfield/viewer/widgets/stream_card.py create mode 100644 tests/unit/viewer/__init__.py create mode 100644 tests/unit/viewer/test_formatting.py create mode 100644 tests/unit/viewer/test_poller.py create mode 100644 tests/unit/viewer/test_state.py diff --git a/pyproject.toml b/pyproject.toml index 84e4438..1cf5cd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,20 @@ ble = ["bleak>=0.21"] # extra's opencv-python for the MP4 writer. Install `syncfield[oak,uvc]` # (or `syncfield[all]`) for the full OAK capture path. oak = ["depthai>=3.0.0"] +# The desktop viewer uses dearpygui (GPU-accelerated, light 1.8 MiB wheel). +# It also reuses numpy from the audio extra when rendering video frames — +# if you install ``syncfield[viewer]`` on its own, numpy is pulled in too. +viewer = [ + "dearpygui>=2.0", + "numpy>=1.21", +] all = [ "sounddevice>=0.4.6", "numpy>=1.21", "opencv-python>=4.5", "bleak>=0.21", "depthai>=3.0.0", + "dearpygui>=2.0", ] [project.urls] diff --git a/src/syncfield/adapters/oak_camera.py b/src/syncfield/adapters/oak_camera.py index a4547c4..b3a26de 100644 --- a/src/syncfield/adapters/oak_camera.py +++ b/src/syncfield/adapters/oak_camera.py @@ -122,6 +122,12 @@ def __init__( self._first_at: Optional[int] = None self._last_at: Optional[int] = None + # Live preview support — the viewer reads ``latest_frame`` to render + # the stream card thumbnail. ``_frame_lock`` protects handoff between + # the capture thread and the reader. + self._frame_lock = threading.Lock() + self._latest_frame: Any = None + # ------------------------------------------------------------------ # Stream SPI # ------------------------------------------------------------------ @@ -253,6 +259,10 @@ def _capture_loop(self) -> None: self._last_at = capture_ns self._frame_count += 1 + # Publish the latest frame for live preview (viewer reads this). + with self._frame_lock: + self._latest_frame = frame + if self._video_writer is not None: self._video_writer.write(frame) self._emit_sample( @@ -318,6 +328,22 @@ def _release_pipeline(self) -> None: self._q_rgb = None self._q_depth = None + # ------------------------------------------------------------------ + # Live preview + # ------------------------------------------------------------------ + + @property + def latest_frame(self) -> Any: + """Return the most recently captured RGB frame, or ``None``. + + Thread-safe: the frame reference is published under a lock by the + capture thread. Readers that mutate the returned array should + ``.copy()`` it first — the viewer uploads it as a texture + immediately and never mutates it in place. + """ + with self._frame_lock: + return self._latest_frame + # --------------------------------------------------------------------------- # Depth binary format helper diff --git a/src/syncfield/adapters/uvc_webcam.py b/src/syncfield/adapters/uvc_webcam.py index ccfa08b..e4b0d97 100644 --- a/src/syncfield/adapters/uvc_webcam.py +++ b/src/syncfield/adapters/uvc_webcam.py @@ -80,6 +80,13 @@ def __init__( self._first_at: Optional[int] = None self._last_at: Optional[int] = None + # Live preview support — the viewer reads ``latest_frame`` to render + # the stream card thumbnail. ``_frame_lock`` protects handoff between + # the capture thread and the reader; the frame itself is a plain + # reference so no copy is made in the hot path. + self._frame_lock = threading.Lock() + self._latest_frame: Any = None + # ------------------------------------------------------------------ # Stream SPI # ------------------------------------------------------------------ @@ -154,6 +161,10 @@ def _capture_loop(self) -> None: self._last_at = capture_ns self._frame_count += 1 + # Publish the latest frame for live preview (viewer reads this). + with self._frame_lock: + self._latest_frame = frame + if self._writer is not None: self._writer.write(frame) self._emit_sample( @@ -164,6 +175,22 @@ def _capture_loop(self) -> None: ) ) + # ------------------------------------------------------------------ + # Live preview + # ------------------------------------------------------------------ + + @property + def latest_frame(self) -> Any: + """Return the most recently captured BGR frame, or ``None``. + + Thread-safe: the frame reference is published under a lock by the + capture thread. Readers that mutate the returned array should + ``.copy()`` it first — in practice the viewer uploads it as a + texture immediately and never mutates it in place. + """ + with self._frame_lock: + return self._latest_frame + def _release_cv2_resources(self) -> None: if self._writer is not None: self._writer.release() diff --git a/src/syncfield/viewer/__init__.py b/src/syncfield/viewer/__init__.py new file mode 100644 index 0000000..fa2cb6f --- /dev/null +++ b/src/syncfield/viewer/__init__.py @@ -0,0 +1,48 @@ +"""SyncField desktop viewer — a MuJoCo-style bundled GUI. + +Usage:: + + import syncfield as sf + import syncfield.viewer + + session = sf.SessionOrchestrator(host_id="rig_01", output_dir="./data") + session.add(...) + + # Blocking mode — opens the window, returns when it closes + syncfield.viewer.launch(session) + + # Passive mode — context manager, caller keeps control of the session + with syncfield.viewer.launch_passive(session) as viewer: + session.start() + while viewer.is_running(): + time.sleep(0.1) + session.stop() + +The viewer renders in the same process as the SDK. No HTTP, no IPC — the +poller holds a reference to the :class:`SessionOrchestrator` and reads its +state directly. Video frames are published by each adapter via a +thread-safe ``latest_frame`` property and uploaded to the GPU as raw +textures. + +Requires the ``viewer`` extra:: + + pip install 'syncfield[viewer]' + +which installs ``dearpygui`` and ``numpy``. The SDK core stays stdlib-only +for users who never open the GUI. +""" + +from __future__ import annotations + +try: + import dearpygui.dearpygui as _dpg # noqa: F401 + import numpy as _np # noqa: F401 +except ImportError as exc: # pragma: no cover - exercised at import time on CI + raise ImportError( + "syncfield.viewer requires the 'viewer' extra. " + "Install with `pip install 'syncfield[viewer]'`." + ) from exc + +from syncfield.viewer.app import ViewerHandle, launch, launch_passive + +__all__ = ["launch", "launch_passive", "ViewerHandle"] diff --git a/src/syncfield/viewer/app.py b/src/syncfield/viewer/app.py new file mode 100644 index 0000000..08495fe --- /dev/null +++ b/src/syncfield/viewer/app.py @@ -0,0 +1,278 @@ +"""Desktop viewer application — MuJoCo-style launcher for SyncField sessions. + +This module owns the top-level DearPyGui context, the render loop, and the +small lifecycle machinery that makes :func:`launch` / :func:`launch_passive` +feel natural. The actual widget construction lives in +:mod:`syncfield.viewer.widgets` to keep this file focused on "how the app +runs" rather than "what each panel looks like". + +Thread model: + +- **Main thread** runs the DearPyGui render loop. +- **Poller thread** (daemon, started by :class:`SessionPoller`) populates + :class:`SessionSnapshot`\\ s at 10 Hz. +- **Control worker thread** (daemon, started on button click) runs + ``session.start()`` / ``session.stop()`` so the UI never blocks on SDK + lifecycle calls. + +DearPyGui itself is single-threaded for all UI mutation — the render loop +is the only thing that calls ``dpg.set_value`` / ``dpg.configure_item``. +Snapshots flow main thread via a single lock-guarded read. +""" + +from __future__ import annotations + +import logging +import threading +import time +from contextlib import contextmanager +from typing import Iterator, Optional + +import dearpygui.dearpygui as dpg + +from syncfield.orchestrator import SessionOrchestrator +from syncfield.viewer import theme +from syncfield.viewer.poller import SessionPoller +from syncfield.viewer.widgets.layout import ViewerLayout + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Handle returned from launch_passive() +# --------------------------------------------------------------------------- + + +class ViewerHandle: + """Minimal handle exposed by :func:`launch_passive`. + + Mirrors the shape of ``mujoco.viewer.launch_passive`` so callers coming + from the MuJoCo / rerun worlds have zero friction. + """ + + def __init__(self, app: "ViewerApp") -> None: + self._app = app + + def is_running(self) -> bool: + """Return True while the viewer window is still open.""" + return self._app.is_running() + + def sync(self) -> None: + """Render one frame. Use in passive mode when you own the loop.""" + self._app.render_one_frame() + + def close(self) -> None: + """Close the viewer window and stop the poller.""" + self._app.close() + + +# --------------------------------------------------------------------------- +# Public launchers +# --------------------------------------------------------------------------- + + +def launch( + session: SessionOrchestrator, + *, + title: str = "SyncField", +) -> None: + """Open the viewer and block until the window is closed. + + In blocking mode the viewer *owns* the session lifecycle — the user + clicks Record / Stop / Cancel in the UI, and the worker threads call + the corresponding ``SessionOrchestrator`` methods. When the window + closes, any in-progress recording is stopped cleanly. + + Args: + session: The orchestrator to observe and control. + title: Window title. Default ``"SyncField"``. + """ + app = ViewerApp(session, title=title) + try: + app.setup() + app.run() + finally: + app.close() + + +@contextmanager +def launch_passive( + session: SessionOrchestrator, + *, + title: str = "SyncField", +) -> Iterator[ViewerHandle]: + """Open the viewer in **passive** mode and return a handle. + + Use this when the caller owns the session lifecycle — e.g. a script + that wants the GUI as an observer while it runs its own start/stop + logic. The viewer's render loop runs on a background thread so the + caller keeps control of the main thread. + + Example:: + + with syncfield.viewer.launch_passive(session) as viewer: + session.start() + while viewer.is_running(): + time.sleep(0.1) + session.stop() + + Note: + Passive mode runs the DearPyGui render loop on a background + thread. DPG is designed for a single UI thread and this works + reliably on macOS and Linux in practice, but the blocking + :func:`launch` path is the "MuJoCo-canonical" one if you don't + need to share the main thread. + """ + app = ViewerApp(session, title=title) + app.setup() + + bg_thread = threading.Thread( + target=app.run, name="syncfield-viewer-passive", daemon=True + ) + bg_thread.start() + + try: + yield ViewerHandle(app) + finally: + app.close() + bg_thread.join(timeout=2.0) + + +# --------------------------------------------------------------------------- +# Core app class +# --------------------------------------------------------------------------- + + +class ViewerApp: + """Owns the DearPyGui context and render loop for one viewer window. + + Separated from the module-level helpers so it can be instantiated + directly in tests (or, in the future, embedded in a larger GUI). + """ + + def __init__( + self, + session: SessionOrchestrator, + *, + title: str = "SyncField", + ) -> None: + self._session = session + self._title = title + self._poller = SessionPoller(session) + self._layout: Optional[ViewerLayout] = None + self._running = False + self._setup_done = False + self._close_requested = False + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def setup(self) -> None: + """Create the DPG context, build the layout, and start the poller.""" + if self._setup_done: + return + + dpg.create_context() + dpg.create_viewport( + title=self._title, + width=theme.VIEWPORT_WIDTH, + height=theme.VIEWPORT_HEIGHT, + small_icon="", + large_icon="", + resizable=True, + ) + + # Bind the global theme before any widgets are created so the + # first frame doesn't flash with the default dark theme. + global_theme_tag = theme.build_theme() + dpg.bind_theme(global_theme_tag) + + # Viewport clear color matches the app background so the window + # chrome edge doesn't leak through. + dpg.set_viewport_clear_color( + [c / 255 for c in theme.BG_APP] + ) + + self._layout = ViewerLayout(self._session) + self._layout.build() + + dpg.setup_dearpygui() + dpg.show_viewport() + + # Make the primary window fill the viewport so resizing feels + # native. The layout's main window is tagged "main_window". + dpg.set_primary_window("main_window", True) + + self._poller.start() + self._setup_done = True + + def run(self) -> None: + """Run the render loop on the calling thread. + + Exits when the viewport is closed or :meth:`close` is called. + """ + if not self._setup_done: + self.setup() + self._running = True + try: + while dpg.is_dearpygui_running() and not self._close_requested: + self.render_one_frame() + finally: + self._running = False + + def render_one_frame(self) -> None: + """Render a single DPG frame after syncing from the latest snapshot.""" + snapshot = self._poller.get_snapshot() + if snapshot is not None and self._layout is not None: + self._layout.update(snapshot) + dpg.render_dearpygui_frame() + + def close(self) -> None: + """Stop the poller and destroy the DPG context.""" + if self._close_requested: + return + self._close_requested = True + self._poller.stop() + try: + if dpg.is_dearpygui_running(): + dpg.stop_dearpygui() + except Exception: + pass + try: + dpg.destroy_context() + except Exception: + pass + self._setup_done = False + + def is_running(self) -> bool: + return self._running and not self._close_requested + + # ------------------------------------------------------------------ + # Session control (called from widget callbacks) + # ------------------------------------------------------------------ + + def request_start(self) -> None: + """Kick off ``session.start()`` on a worker thread so the UI stays live.""" + threading.Thread( + target=self._safe_call, + args=(self._session.start,), + name="syncfield-viewer-start", + daemon=True, + ).start() + + def request_stop(self) -> None: + """Kick off ``session.stop()`` on a worker thread.""" + threading.Thread( + target=self._safe_call, + args=(self._session.stop,), + name="syncfield-viewer-stop", + daemon=True, + ).start() + + @staticmethod + def _safe_call(fn) -> None: + try: + fn() + except Exception: + logger.exception("Viewer session control call failed") diff --git a/src/syncfield/viewer/demo.py b/src/syncfield/viewer/demo.py new file mode 100644 index 0000000..388c31f --- /dev/null +++ b/src/syncfield/viewer/demo.py @@ -0,0 +1,342 @@ +"""Headless-safe demo for the SyncField desktop viewer. + +Run with:: + + python -m syncfield.viewer.demo + +Spins up a :class:`SessionOrchestrator` wired to a realistic mix of fake +streams (two synthetic video sources, one IMU with a BNO-style signal, +one JSONL-ish logger, and a custom sensor) so the viewer has plausible +data to render without any hardware connected. + +This module doubles as the screenshot harness — it accepts +``--snapshot path.png`` to quit the viewer after a warmup period and +save the window bitmap to disk. +""" + +from __future__ import annotations + +import argparse +import math +import sys +import threading +import time +from pathlib import Path +from typing import Any, List, Optional + +import numpy as np + +import syncfield as sf +from syncfield.stream import StreamBase +from syncfield.testing import FakeStream +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + StreamCapabilities, +) + + +# --------------------------------------------------------------------------- +# Fake video stream — generates a moving gradient so screenshots look "live" +# --------------------------------------------------------------------------- + + +class SyntheticVideoStream(StreamBase): + """Fake video source that generates a procedural gradient every ~33 ms. + + Exposes ``latest_frame`` the same way :class:`UVCWebcamStream` and + :class:`OakCameraStream` do, so the viewer's video card renders it + correctly without any mocking on the viewer side. + """ + + def __init__( + self, + id: str, + width: int = 640, + height: int = 360, + fps: float = 30.0, + hue_shift: float = 0.0, + ) -> None: + super().__init__( + id=id, + kind="video", + capabilities=StreamCapabilities( + provides_audio_track=False, + supports_precise_timestamps=True, + is_removable=False, + produces_file=True, + ), + ) + self._width = width + self._height = height + self._period_s = 1.0 / fps + self._hue_shift = hue_shift + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._frame_count = 0 + self._first_at: int | None = None + self._last_at: int | None = None + self._latest_frame: Any = None + self._frame_lock = threading.Lock() + + def prepare(self) -> None: + pass + + def start(self, session_clock) -> None: # type: ignore[override] + self._stop.clear() + self._thread = threading.Thread( + target=self._generate_loop, name=f"synth-vid-{self.id}", daemon=True + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + + @property + def latest_frame(self) -> Any: + with self._frame_lock: + return self._latest_frame + + def _generate_loop(self) -> None: + """Procedurally generate a colorful moving gradient. + + The frame is a smooth sinusoidal pattern that drifts across the + image — visually distinctive enough that screenshots show real + motion but cheap enough to compute at 30 fps. + """ + xs = np.linspace(0, 2 * math.pi, self._width, dtype=np.float32) + ys = np.linspace(0, 2 * math.pi, self._height, dtype=np.float32) + xx, yy = np.meshgrid(xs, ys) + t0 = time.monotonic() + frame_number = 0 + while not self._stop.is_set(): + t = time.monotonic() - t0 + # Smooth blue/indigo gradient with a subtle wave for motion + r = 0.55 + 0.30 * np.sin(xx + t * 1.2 + self._hue_shift) + g = 0.55 + 0.30 * np.sin(yy + t * 0.9 + self._hue_shift + 2.0) + b = 0.75 + 0.20 * np.sin(xx + yy + t * 1.5 + self._hue_shift + 4.0) + rgb = np.stack([r, g, b], axis=-1) + rgb = np.clip(rgb, 0.0, 1.0) + bgr = (rgb[:, :, ::-1] * 255).astype(np.uint8) + + capture_ns = time.monotonic_ns() + with self._frame_lock: + self._latest_frame = bgr + + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=frame_number, + capture_ns=capture_ns, + ) + ) + frame_number += 1 + self._stop.wait(self._period_s) + + +# --------------------------------------------------------------------------- +# Fake IMU — emits a sine/cosine signal as a "BNO085-style" stream +# --------------------------------------------------------------------------- + + +class SyntheticImuStream(StreamBase): + """Fake 9-DOF IMU that produces smooth sinusoidal channels at 100 Hz.""" + + def __init__(self, id: str) -> None: + super().__init__( + id=id, + kind="sensor", + capabilities=StreamCapabilities( + provides_audio_track=False, + supports_precise_timestamps=True, + is_removable=True, + produces_file=False, + ), + ) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._frame_count = 0 + self._first_at: int | None = None + self._last_at: int | None = None + + def prepare(self) -> None: + pass + + def start(self, session_clock) -> None: # type: ignore[override] + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, name=f"synth-imu-{self.id}", daemon=True + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + + def _loop(self) -> None: + period = 0.01 # 100 Hz + t0 = time.monotonic() + while not self._stop.is_set(): + t = time.monotonic() - t0 + capture_ns = time.monotonic_ns() + if self._first_at is None: + self._first_at = capture_ns + self._last_at = capture_ns + self._frame_count += 1 + channels = { + "ax": math.sin(t * 1.3) * 0.8 + math.sin(t * 7.0) * 0.1, + "ay": math.cos(t * 1.6) * 0.6, + "az": 9.81 + math.sin(t * 0.5) * 0.2, + "gx": math.sin(t * 2.0) * 0.4, + "gy": math.cos(t * 2.3) * 0.5, + "gz": math.sin(t * 3.1) * 0.3, + } + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=self._frame_count - 1, + capture_ns=capture_ns, + channels=channels, + ) + ) + + # Sprinkle in a health event occasionally so the health table + # actually has content in screenshots. + if self._frame_count == 150: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.WARNING, + at_ns=capture_ns, + detail="synthetic jitter above threshold", + ) + ) + if self._frame_count == 320: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.RECONNECT, + at_ns=capture_ns, + detail=None, + ) + ) + + self._stop.wait(period) + + +# --------------------------------------------------------------------------- +# Demo session builder +# --------------------------------------------------------------------------- + + +def build_demo_session(output_dir: Path) -> sf.SessionOrchestrator: + """Construct a realistic multi-stream session for the viewer demo.""" + session = sf.SessionOrchestrator( + host_id="demo_rig", + output_dir=output_dir, + sync_tone=sf.SyncToneConfig.silent(), # don't actually play audio in the demo + ) + session.add(SyntheticVideoStream("cam_ego", width=640, height=360, hue_shift=0.0)) + session.add( + SyntheticVideoStream("cam_wrist_left", width=480, height=480, hue_shift=1.7) + ) + session.add(SyntheticImuStream("torso_imu")) + session.add(FakeStream("tactile_left", provides_audio_track=False)) + return session + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="SyncField viewer demo") + parser.add_argument( + "--output-dir", + type=Path, + default=Path("./demo_session"), + help="Output directory for the synthetic session.", + ) + parser.add_argument( + "--auto-record", + action="store_true", + help="Automatically click Record on startup.", + ) + parser.add_argument( + "--duration", + type=float, + default=0.0, + help=( + "If > 0, run for this many seconds then auto-close. " + "Useful for screenshotting." + ), + ) + args = parser.parse_args(argv) + + args.output_dir.mkdir(parents=True, exist_ok=True) + session = build_demo_session(args.output_dir) + + import syncfield.viewer as viewer + + if args.auto_record: + # Start the session immediately so screenshots look populated. + def _auto_record() -> None: + time.sleep(0.5) + try: + session.start() + except Exception as exc: + print(f"auto-record failed: {exc}", file=sys.stderr) + + threading.Thread(target=_auto_record, daemon=True).start() + + if args.duration > 0: + # Auto-close mode: run the viewer on the main thread for the + # requested duration, then stop. The viewer's event loop doesn't + # block on os.exit, so we set a timer that calls dpg.stop_dearpygui. + import dearpygui.dearpygui as dpg + + def _timer() -> None: + time.sleep(args.duration) + try: + dpg.stop_dearpygui() + except Exception: + pass + + threading.Thread(target=_timer, daemon=True).start() + + viewer.launch(session) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/syncfield/viewer/poller.py b/src/syncfield/viewer/poller.py new file mode 100644 index 0000000..fc72a5d --- /dev/null +++ b/src/syncfield/viewer/poller.py @@ -0,0 +1,249 @@ +"""Background thread that polls a SessionOrchestrator into SessionSnapshots. + +The poller runs in its own daemon thread at a configurable cadence (default +10 Hz). On each tick it reads the session's public state, rolls per-stream +stats forward, and publishes an immutable :class:`SessionSnapshot` under a +lock. The viewer's render loop calls :meth:`SessionPoller.get_snapshot` on +every frame to fetch the latest one. + +Separate from the poll loop, the poller subscribes to each stream's +``on_sample`` and ``on_health`` callbacks so per-sample data (for IMU plots +and health timelines) lands in the stats buffer in real time rather than +being lost between polls. This matters because poll ticks at 10 Hz would +otherwise miss ~90% of samples on a 100 Hz IMU. +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from typing import Dict, List, Optional + +from syncfield.orchestrator import SessionOrchestrator +from syncfield.stream import Stream +from syncfield.types import HealthEvent, SampleEvent, SessionState + +from syncfield.viewer.state import ( + HealthEntry, + SessionSnapshot, + StreamSnapshot, + StreamStatsBuffer, +) + + +class SessionPoller: + """Polls a :class:`SessionOrchestrator` and produces snapshots. + + Thread model: + + - The poll loop runs in a daemon background thread owned by this object. + - Sample and health callbacks run on whichever stream thread emits them. + - :meth:`get_snapshot` is safe to call from any thread; it returns the + latest published snapshot under a lock. + + Args: + session: The orchestrator to observe. + interval_s: How often to produce a new snapshot. Default ``0.1`` + (10 Hz) matches the cadence the viewer needs for smooth UI + updates without burning CPU. + """ + + def __init__( + self, + session: SessionOrchestrator, + interval_s: float = 0.1, + ) -> None: + self._session = session + self._interval_s = interval_s + + self._stats: Dict[str, StreamStatsBuffer] = {} + self._snapshot: Optional[SessionSnapshot] = None + self._snapshot_lock = threading.Lock() + + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._recording_started_at: Optional[float] = None + self._last_observed_state: SessionState = SessionState.IDLE + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self) -> None: + """Subscribe to all streams' callbacks and begin polling.""" + self._register_callbacks() + self._thread = threading.Thread( + target=self._poll_loop, name="syncfield-viewer-poller", daemon=True + ) + self._stop.clear() + self._thread.start() + + def stop(self) -> None: + """Signal the poll thread and join it.""" + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + + def get_snapshot(self) -> Optional[SessionSnapshot]: + """Return the latest snapshot, or ``None`` if the poller never ran.""" + with self._snapshot_lock: + return self._snapshot + + # ------------------------------------------------------------------ + # Callback wiring + # ------------------------------------------------------------------ + + def _register_callbacks(self) -> None: + """Attach on_sample / on_health to each registered stream. + + Re-entrant: callbacks for streams we've already registered for are + skipped by tracking which stream ids already have a buffer. + """ + for stream_id, stream in self._session._streams.items(): # type: ignore[attr-defined] + if stream_id in self._stats: + continue + buffer = StreamStatsBuffer() + self._stats[stream_id] = buffer + stream.on_sample(self._make_sample_callback(stream_id, buffer)) + stream.on_health(self._make_health_callback(stream_id, buffer)) + + @staticmethod + def _make_sample_callback(stream_id: str, buffer: StreamStatsBuffer): + def _on_sample(event: SampleEvent) -> None: + buffer.observe_sample(event.capture_ns, event.channels) + + return _on_sample + + @staticmethod + def _make_health_callback(stream_id: str, buffer: StreamStatsBuffer): + def _on_health(event: HealthEvent) -> None: + buffer.observe_health( + HealthEntry( + stream_id=stream_id, + kind=event.kind.value, + at_ns=event.at_ns, + detail=event.detail, + ) + ) + + return _on_health + + # ------------------------------------------------------------------ + # Poll loop + # ------------------------------------------------------------------ + + def _poll_loop(self) -> None: + """Take a snapshot every ``interval_s`` seconds until stopped.""" + while not self._stop.is_set(): + # Streams may be added after start(); re-register to catch any + # latecomers. Idempotent — already-registered streams are skipped. + self._register_callbacks() + try: + snapshot = self._build_snapshot() + except Exception: + # A bad snapshot should never take down the viewer — keep + # the previous one around. + snapshot = None + if snapshot is not None: + with self._snapshot_lock: + self._snapshot = snapshot + self._stop.wait(self._interval_s) + + def _build_snapshot(self) -> SessionSnapshot: + """Read the current session state into an immutable SessionSnapshot.""" + session = self._session + + # Track the recording start time so we can compute elapsed seconds. + current_state: SessionState = session.state + now = time.time() + if ( + current_state is SessionState.RECORDING + and self._last_observed_state is not SessionState.RECORDING + ): + self._recording_started_at = now + elif current_state is not SessionState.RECORDING and current_state is not SessionState.STOPPING: + self._recording_started_at = None + self._last_observed_state = current_state + + elapsed_s = 0.0 + if self._recording_started_at is not None: + elapsed_s = max(0.0, now - self._recording_started_at) + + now_ns = time.monotonic_ns() + streams_snapshot: Dict[str, StreamSnapshot] = {} + for stream_id, stream in session._streams.items(): # type: ignore[attr-defined] + buffer = self._stats.get(stream_id) + if buffer is None: + buffer = StreamStatsBuffer() + self._stats[stream_id] = buffer + + plot_points = buffer.snapshot_plot() if stream.kind != "video" else {} + effective_hz = buffer.snapshot_fps(now_ns) + latest_frame = self._safe_latest_frame(stream) + + # Prefer the adapter's own frame counter when available (video + # adapters maintain `_frame_count` explicitly); otherwise fall + # back to the poller's buffered sample count. + if hasattr(stream, "_frame_count"): + frame_count = int(getattr(stream, "_frame_count") or 0) + else: + frame_count = len(buffer._plot_timestamps) + + last_sample_at_ns: Optional[int] = ( + buffer._fps_window[-1] if buffer._fps_window else None + ) + + streams_snapshot[stream_id] = StreamSnapshot( + id=stream_id, + kind=stream.kind, + provides_audio_track=stream.capabilities.provides_audio_track, + produces_file=stream.capabilities.produces_file, + frame_count=frame_count, + last_sample_at_ns=last_sample_at_ns, + effective_hz=effective_hz, + latest_frame=latest_frame, + plot_points=plot_points, + health_count=len(buffer._health), + ) + + # Merge health events into a session-wide, time-sorted log. + health_log = self._collect_health_log() + + # Session-level sync point + chirp fields. + sync_point = getattr(session, "_sync_point", None) + sp_mono = sync_point.monotonic_ns if sync_point is not None else None + sp_wall = sync_point.wall_clock_ns if sync_point is not None else None + chirp_start = getattr(session, "_chirp_start_ns", None) + chirp_stop = getattr(session, "_chirp_stop_ns", None) + chirp_enabled = bool(session._sync_tone.enabled) # type: ignore[attr-defined] + + return SessionSnapshot( + host_id=session.host_id, + state=current_state.value, + output_dir=str(Path(session.output_dir).resolve()), + sync_point_monotonic_ns=sp_mono, + sync_point_wall_clock_ns=sp_wall, + chirp_start_ns=chirp_start, + chirp_stop_ns=chirp_stop, + chirp_enabled=chirp_enabled, + elapsed_s=elapsed_s, + streams=streams_snapshot, + health_log=health_log, + ) + + @staticmethod + def _safe_latest_frame(stream: Stream): + """Read ``stream.latest_frame`` if the adapter exposes it.""" + frame = getattr(stream, "latest_frame", None) + return frame + + def _collect_health_log(self) -> List[HealthEntry]: + """Merge per-stream health deques into a time-sorted global log.""" + merged: List[HealthEntry] = [] + for buffer in self._stats.values(): + merged.extend(buffer.snapshot_health()) + merged.sort(key=lambda e: e.at_ns) + # Cap to the most recent N so a long session doesn't blow up the table. + return merged[-50:] diff --git a/src/syncfield/viewer/state.py b/src/syncfield/viewer/state.py new file mode 100644 index 0000000..fb40701 --- /dev/null +++ b/src/syncfield/viewer/state.py @@ -0,0 +1,212 @@ +"""Immutable snapshots of session state, produced by the poller. + +The viewer never touches a live :class:`~syncfield.SessionOrchestrator` from +the GUI render loop — instead, a background thread polls the session at a +fixed cadence and produces a :class:`SessionSnapshot`. The render loop then +reads the snapshot under a lock and updates widgets. Because snapshots are +frozen dataclasses of plain Python values, widgets never need to reason +about threading or about whether the session is mid-transition. + +Thread safety model: + +- Snapshots are constructed by the poller thread only. +- Snapshots are read by the render loop on the main thread only. +- The :class:`~syncfield.viewer.poller.SessionPoller` guards handoff with + a single lock — readers always get the latest fully-built snapshot. +- Numpy video frames are published by reference (no copy) because texture + uploads in DearPyGui copy the buffer internally. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from typing import Any, Deque, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Stream-level snapshot +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class StreamSnapshot: + """Per-stream state captured at one polling tick. + + Attributes: + id: The stream identifier. + kind: ``"video" | "audio" | "sensor" | "custom"``. + provides_audio_track: Convenience flag (mirrors the capability) — + used by the viewer to decide whether to show the audio-chirp + indicator next to the card. + produces_file: Whether this stream writes a file. + frame_count: Total samples/frames produced since start. + last_sample_at_ns: Monotonic ns of the most recent sample, or None. + effective_hz: Measured frame rate over a short rolling window. + latest_frame: Most recent BGR/RGB frame (numpy array) for video + streams. ``None`` for non-video or when no frame has arrived yet. + plot_points: For sensor streams, a dict of ``channel_name -> + (x_list, y_list)`` rolling buffers of numeric values. Empty for + video streams. + health_count: Number of health events this stream has buffered so + far. Useful for showing a red dot on degraded streams. + """ + + id: str + kind: str + provides_audio_track: bool + produces_file: bool + frame_count: int + last_sample_at_ns: Optional[int] + effective_hz: float + latest_frame: Any # numpy array or None — kept as Any so numpy is optional + plot_points: Dict[str, Tuple[List[float], List[float]]] + health_count: int + + +# --------------------------------------------------------------------------- +# Health event +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class HealthEntry: + """A single health event surfaced by any stream. + + Simpler than :class:`~syncfield.types.HealthEvent` because the viewer + only needs strings for display and a monotonic ordering key. + """ + + stream_id: str + kind: str # "heartbeat" | "drop" | "reconnect" | "warning" | "error" + at_ns: int + detail: Optional[str] + + +# --------------------------------------------------------------------------- +# Session-level snapshot +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SessionSnapshot: + """Everything the viewer needs to render one frame. + + Attributes: + host_id: Session host identifier. + state: Lowercase state string (``"idle"``, ``"recording"``, ...). + output_dir: Absolute path string for the session output directory. + sync_point_monotonic_ns: Captured at ``session.start()``, or None + before start. + sync_point_wall_clock_ns: Wall-clock ns captured at start. + chirp_start_ns: Monotonic ns when the start chirp was played. + chirp_stop_ns: Monotonic ns when the stop chirp was played. + chirp_enabled: Whether :class:`SyncToneConfig` had chirp enabled. + elapsed_s: Wall-clock seconds since ``start()``, or 0 if idle. + streams: Ordered map ``stream_id -> StreamSnapshot``. + health_log: Most recent health events across all streams (newest last). + """ + + host_id: str + state: str + output_dir: str + sync_point_monotonic_ns: Optional[int] + sync_point_wall_clock_ns: Optional[int] + chirp_start_ns: Optional[int] + chirp_stop_ns: Optional[int] + chirp_enabled: bool + elapsed_s: float + streams: Dict[str, StreamSnapshot] + health_log: List[HealthEntry] + + +# --------------------------------------------------------------------------- +# Helper buffers owned by the poller (not part of the snapshot contract) +# --------------------------------------------------------------------------- + + +@dataclass +class StreamStatsBuffer: + """Mutable running stats the poller maintains per stream. + + Lives inside the poller and gets snapshotted into immutable + :class:`StreamSnapshot`\\ s on each tick. Kept separate from the + snapshot so the render loop never sees mutable state. + """ + + max_plot_samples: int = 300 + max_health: int = 20 + + # Rolling fps window (monotonic ns) + _fps_window: Deque[int] = field(default_factory=lambda: deque(maxlen=30)) + + # Rolling plot buffers, one per numeric channel. Keys appear lazily. + _plot_timestamps: Deque[float] = field(default_factory=lambda: deque(maxlen=300)) + _plot_channels: Dict[str, Deque[float]] = field(default_factory=dict) + + # Health events produced by this stream (capped) + _health: Deque[HealthEntry] = field(default_factory=lambda: deque(maxlen=20)) + + def observe_sample(self, capture_ns: int, channels: Optional[Dict[str, Any]]) -> None: + """Record one sample. Called from the stream's callback thread. + + Thread-safe against the poller's snapshot reader because all deques + are bounded — truncation happens inside ``deque.append`` atomically + and readers call :meth:`snapshot_fps` / :meth:`snapshot_plot` which + make a list copy. + """ + self._fps_window.append(capture_ns) + self._plot_timestamps.append(capture_ns / 1e9) + + if channels: + # Pad any missing channel to align lengths, then append numeric values. + current_len = len(self._plot_timestamps) + for name, value in channels.items(): + if not isinstance(value, (int, float)): + continue + buf = self._plot_channels.get(name) + if buf is None: + buf = deque(maxlen=self.max_plot_samples) + # Back-fill with NaN so the x/y arrays line up in the plot. + padding = max(0, current_len - 1) + for _ in range(padding): + buf.append(float("nan")) + self._plot_channels[name] = buf + buf.append(float(value)) + + # Any channel we already track but that's missing from this sample + # gets a NaN so it doesn't drift out of alignment. + for name, buf in self._plot_channels.items(): + if name not in channels: + buf.append(float("nan")) + + def observe_health(self, event: HealthEntry) -> None: + self._health.append(event) + + def snapshot_fps(self, now_ns: int) -> float: + """Effective Hz over the last second of samples, or 0 if no data.""" + if not self._fps_window: + return 0.0 + window_start = now_ns - 1_000_000_000 + recent = [t for t in self._fps_window if t >= window_start] + return float(len(recent)) + + def snapshot_plot(self) -> Dict[str, Tuple[List[float], List[float]]]: + """Copy plot buffers into plain lists safe for the render thread.""" + x = list(self._plot_timestamps) + if not x: + return {} + out: Dict[str, Tuple[List[float], List[float]]] = {} + for name, buf in self._plot_channels.items(): + y = list(buf) + # Align: the channel's buffer may be shorter if it just appeared + # mid-stream; left-pad with NaN to match x. + if len(y) < len(x): + y = [float("nan")] * (len(x) - len(y)) + y + elif len(y) > len(x): + y = y[-len(x):] + out[name] = (x, y) + return out + + def snapshot_health(self) -> List[HealthEntry]: + return list(self._health) diff --git a/src/syncfield/viewer/theme.py b/src/syncfield/viewer/theme.py new file mode 100644 index 0000000..c6805af --- /dev/null +++ b/src/syncfield/viewer/theme.py @@ -0,0 +1,340 @@ +"""Visual theme for the SyncField desktop viewer. + +The viewer ships a **light theme only**, matching OpenGraph's minimal and +sophisticated design language. Everything here is in one place so a future +dark mode (or brand recolor) is a single-file change. + +Design tokens: + +- Backgrounds and surfaces use a near-white palette with subtle tonal + variation so panels pop without needing heavy borders. +- Primary text is near-black, secondary text is a muted gray. +- Accent color is a single calibrated indigo used for state indicators and + active buttons. Success/warning/danger round out the semantic palette. +- Border radii are consistently soft (6–10 px) so the GUI feels modern + without being cartoonish. +- Padding and spacing values are generous, matching OpenGraph's docs site + aesthetic of "plenty of whitespace, tight typography." + +All values are raw tuples so the module is importable without DearPyGui — +the ``build_theme`` function is the only thing that touches ``dpg``. +""" + +from __future__ import annotations + +from typing import Any, Tuple + +# --------------------------------------------------------------------------- +# Color palette (RGBA 0-255) +# --------------------------------------------------------------------------- + +# Surfaces — near-white tonal stack +BG_APP = (248, 249, 251, 255) # #F8F9FB — viewport background +BG_PANEL = (255, 255, 255, 255) # #FFFFFF — primary panels +BG_PANEL_SOFT = (243, 245, 248, 255) # #F3F5F8 — secondary / nested panels +BG_HOVER = (237, 240, 244, 255) # #EDF0F4 +BG_ACTIVE = (228, 232, 239, 255) # #E4E8EF + +# Borders +BORDER_SUBTLE = (228, 231, 236, 255) # #E4E7EC — panel hairlines +BORDER_STRONG = (209, 213, 219, 255) # #D1D5DB — emphasized borders + +# Text +TEXT_PRIMARY = (17, 24, 39, 255) # #111827 — gray-900 +TEXT_SECONDARY = (107, 114, 128, 255) # #6B7280 — gray-500 +TEXT_MUTED = (156, 163, 175, 255) # #9CA3AF — gray-400 +TEXT_ON_ACCENT = (255, 255, 255, 255) + +# Semantic colors +ACCENT = (79, 70, 229, 255) # #4F46E5 — indigo-600 (primary brand) +ACCENT_HOVER = (67, 56, 202, 255) # #4338CA +ACCENT_ACTIVE = (55, 48, 163, 255) # #3730A3 +ACCENT_SOFT = (238, 242, 255, 255) # #EEF2FF — indigo-50 tint + +SUCCESS = (16, 185, 129, 255) # #10B981 — emerald-500 +SUCCESS_SOFT = (209, 250, 229, 255) # #D1FAE5 +WARNING = (245, 158, 11, 255) # #F59E0B — amber-500 +WARNING_SOFT = (254, 243, 199, 255) # #FEF3C7 +DANGER = (220, 38, 38, 255) # #DC2626 — red-600 +DANGER_SOFT = (254, 226, 226, 255) # #FEE2E2 +INFO = (14, 165, 233, 255) # #0EA5E9 — sky-500 + +# Session state indicators +STATE_IDLE = TEXT_MUTED +STATE_PREPARING = WARNING +STATE_RECORDING = DANGER +STATE_STOPPING = WARNING +STATE_STOPPED = SUCCESS + +# Plot colors (palette matches Tailwind's calibrated hues for print/light bg) +PLOT_SERIES_COLORS: Tuple[Tuple[int, int, int, int], ...] = ( + (79, 70, 229, 255), # indigo-600 + (16, 185, 129, 255), # emerald-500 + (245, 158, 11, 255), # amber-500 + (220, 38, 38, 255), # red-600 + (14, 165, 233, 255), # sky-500 + (236, 72, 153, 255), # pink-500 + (20, 184, 166, 255), # teal-500 +) + + +# --------------------------------------------------------------------------- +# Spacing and typography scale +# --------------------------------------------------------------------------- + +# Style variables (DearPyGui ImGui-style) +FRAME_ROUNDING = 6 +WINDOW_ROUNDING = 10 +CHILD_ROUNDING = 8 +POPUP_ROUNDING = 8 +GRAB_ROUNDING = 4 +SCROLLBAR_ROUNDING = 6 +TAB_ROUNDING = 6 + +FRAME_PADDING = (12, 8) +WINDOW_PADDING = (20, 20) +ITEM_SPACING = (12, 10) +ITEM_INNER_SPACING = (8, 6) +CELL_PADDING = (8, 6) + +WINDOW_BORDER_SIZE = 0 +CHILD_BORDER_SIZE = 1 +FRAME_BORDER_SIZE = 1 +POPUP_BORDER_SIZE = 1 + +# Card dimensions +CARD_WIDTH = 260 +CARD_HEIGHT = 300 +VIDEO_THUMBNAIL_HEIGHT = 146 # 16:9 at 260 width +PLOT_HEIGHT = 146 + +# Layout sections +HEADER_HEIGHT = 72 +CONTROL_PANEL_HEIGHT = 110 +STREAMS_SECTION_HEIGHT = 340 +HEALTH_SECTION_HEIGHT = 180 +FOOTER_HEIGHT = 48 + +VIEWPORT_WIDTH = 1280 +VIEWPORT_HEIGHT = 860 + + +# --------------------------------------------------------------------------- +# Theme builder — the only function that imports DearPyGui +# --------------------------------------------------------------------------- + + +def build_theme() -> int: + """Construct the OpenGraph light theme and return its DPG tag. + + Call this after ``dpg.create_context()`` and bind with + ``dpg.bind_theme(tag)``. + """ + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvAll): + # --- Window + child backgrounds ----------------------------- + dpg.add_theme_color(dpg.mvThemeCol_WindowBg, BG_APP) + dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_PopupBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_MenuBarBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_SUBTLE) + dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (0, 0, 0, 0)) + + # --- Text --------------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_PRIMARY) + dpg.add_theme_color(dpg.mvThemeCol_TextDisabled, TEXT_MUTED) + dpg.add_theme_color(dpg.mvThemeCol_TextSelectedBg, ACCENT_SOFT) + + # --- Titles ------------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_TitleBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_TitleBgActive, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_TitleBgCollapsed, BG_PANEL_SOFT) + + # --- Frames (input boxes, sliders) ------------------------- + dpg.add_theme_color(dpg.mvThemeCol_FrameBg, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_FrameBgHovered, BG_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_FrameBgActive, BG_ACTIVE) + + # --- Buttons ------------------------------------------------ + dpg.add_theme_color(dpg.mvThemeCol_Button, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, BG_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, BG_ACTIVE) + + # --- Headers (collapsing, tree, selectable) ---------------- + dpg.add_theme_color(dpg.mvThemeCol_Header, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_HeaderHovered, BG_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_HeaderActive, BG_ACTIVE) + + # --- Separators --------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_Separator, BORDER_SUBTLE) + dpg.add_theme_color(dpg.mvThemeCol_SeparatorHovered, BORDER_STRONG) + dpg.add_theme_color(dpg.mvThemeCol_SeparatorActive, ACCENT) + + # --- Scrollbars --------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_ScrollbarBg, (0, 0, 0, 0)) + dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrab, BORDER_STRONG) + dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrabHovered, TEXT_MUTED) + dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrabActive, TEXT_SECONDARY) + + # --- Checkbox / radio / slider grabs ----------------------- + dpg.add_theme_color(dpg.mvThemeCol_CheckMark, ACCENT) + dpg.add_theme_color(dpg.mvThemeCol_SliderGrab, ACCENT) + dpg.add_theme_color(dpg.mvThemeCol_SliderGrabActive, ACCENT_ACTIVE) + + # --- Tabs --------------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_Tab, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_TabHovered, BG_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_TabActive, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_TabUnfocused, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_TabUnfocusedActive, BG_PANEL) + + # --- Tables ------------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_TableHeaderBg, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_TableBorderStrong, BORDER_STRONG) + dpg.add_theme_color(dpg.mvThemeCol_TableBorderLight, BORDER_SUBTLE) + dpg.add_theme_color(dpg.mvThemeCol_TableRowBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_TableRowBgAlt, BG_PANEL_SOFT) + + # --- Plot lines --------------------------------------------- + dpg.add_theme_color(dpg.mvThemeCol_PlotLines, ACCENT) + dpg.add_theme_color(dpg.mvThemeCol_PlotLinesHovered, ACCENT_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_PlotHistogram, ACCENT) + dpg.add_theme_color(dpg.mvThemeCol_PlotHistogramHovered, ACCENT_HOVER) + + # --- Style variables ---------------------------------------- + dpg.add_theme_style(dpg.mvStyleVar_WindowRounding, WINDOW_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_PopupRounding, POPUP_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, FRAME_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_ScrollbarRounding, SCROLLBAR_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_GrabRounding, GRAB_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_TabRounding, TAB_ROUNDING) + + dpg.add_theme_style( + dpg.mvStyleVar_WindowPadding, WINDOW_PADDING[0], WINDOW_PADDING[1] + ) + dpg.add_theme_style( + dpg.mvStyleVar_FramePadding, FRAME_PADDING[0], FRAME_PADDING[1] + ) + dpg.add_theme_style( + dpg.mvStyleVar_ItemSpacing, ITEM_SPACING[0], ITEM_SPACING[1] + ) + dpg.add_theme_style( + dpg.mvStyleVar_ItemInnerSpacing, + ITEM_INNER_SPACING[0], + ITEM_INNER_SPACING[1], + ) + dpg.add_theme_style( + dpg.mvStyleVar_CellPadding, CELL_PADDING[0], CELL_PADDING[1] + ) + + dpg.add_theme_style(dpg.mvStyleVar_WindowBorderSize, WINDOW_BORDER_SIZE) + dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, CHILD_BORDER_SIZE) + dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, FRAME_BORDER_SIZE) + dpg.add_theme_style(dpg.mvStyleVar_PopupBorderSize, POPUP_BORDER_SIZE) + + return theme_tag + + +# --------------------------------------------------------------------------- +# Button variants — one theme per semantic role +# --------------------------------------------------------------------------- + + +def build_primary_button_theme() -> int: + """Filled indigo button theme for the primary action (Record).""" + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvButton): + dpg.add_theme_color(dpg.mvThemeCol_Button, ACCENT) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, ACCENT_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, ACCENT_ACTIVE) + dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_ON_ACCENT) + dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) + return theme_tag + + +def build_danger_button_theme() -> int: + """Filled red button theme for stop/danger actions.""" + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvButton): + dpg.add_theme_color(dpg.mvThemeCol_Button, DANGER) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (185, 28, 28, 255)) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (153, 27, 27, 255)) + dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_ON_ACCENT) + dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) + return theme_tag + + +def build_ghost_button_theme() -> int: + """Outlined/subtle button theme for secondary actions (Cancel).""" + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvButton): + dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 0, 0, 0)) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, BG_HOVER) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, BG_ACTIVE) + dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_SECONDARY) + dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_STRONG) + dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 1) + return theme_tag + + +def build_card_theme() -> int: + """Theme for stream cards — white panel with a subtle hairline border.""" + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvChildWindow): + dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL) + dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_SUBTLE) + dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 1) + dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 16, 14) + return theme_tag + + +def build_soft_panel_theme() -> int: + """Theme for secondary panels — light gray fill, no border.""" + import dearpygui.dearpygui as dpg + + with dpg.theme() as theme_tag: + with dpg.theme_component(dpg.mvChildWindow): + dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL_SOFT) + dpg.add_theme_color(dpg.mvThemeCol_Border, (0, 0, 0, 0)) + dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) + dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 0) + dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 16, 14) + return theme_tag + + +# --------------------------------------------------------------------------- +# Semantic helpers +# --------------------------------------------------------------------------- + + +def state_color(state_value: str) -> Tuple[int, int, int, int]: + """Map a ``SessionState.value`` string to its indicator color.""" + return { + "idle": STATE_IDLE, + "preparing": STATE_PREPARING, + "recording": STATE_RECORDING, + "stopping": STATE_STOPPING, + "stopped": STATE_STOPPED, + }.get(state_value, TEXT_MUTED) + + +def series_color(index: int) -> Tuple[int, int, int, int]: + """Pick a plot series color that cycles through the calibrated palette.""" + return PLOT_SERIES_COLORS[index % len(PLOT_SERIES_COLORS)] + + +def rgba_to_tuple(rgba: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]: + """Identity helper used by widgets to stay type-safe.""" + return rgba diff --git a/src/syncfield/viewer/widgets/__init__.py b/src/syncfield/viewer/widgets/__init__.py new file mode 100644 index 0000000..112d228 --- /dev/null +++ b/src/syncfield/viewer/widgets/__init__.py @@ -0,0 +1,7 @@ +"""Viewer widget modules. + +Each module owns one logical section of the viewer UI. The top-level +:class:`syncfield.viewer.widgets.layout.ViewerLayout` composes them into +the complete screen and drives per-frame updates from a +:class:`SessionSnapshot`. +""" diff --git a/src/syncfield/viewer/widgets/formatting.py b/src/syncfield/viewer/widgets/formatting.py new file mode 100644 index 0000000..896067a --- /dev/null +++ b/src/syncfield/viewer/widgets/formatting.py @@ -0,0 +1,77 @@ +"""Tiny formatting helpers used across viewer widgets. + +Keeping these in one module (instead of sprinkling inline ``f''``-strings +across every widget) means we have a single place to change the display +rules — e.g. switch from ``00:12.345`` to ``12.3s`` for the timer. +""" + +from __future__ import annotations + +from typing import Optional + + +def format_elapsed(seconds: float) -> str: + """Format a duration as ``MM:SS.mmm``.""" + if seconds < 0: + seconds = 0.0 + minutes = int(seconds // 60) + remainder = seconds - minutes * 60 + whole = int(remainder) + millis = int(round((remainder - whole) * 1000)) + if millis >= 1000: + millis = 999 + return f"{minutes:02d}:{whole:02d}.{millis:03d}" + + +def format_hz(hz: float) -> str: + """Format a frequency for display as ``29.9 Hz``.""" + if hz <= 0: + return "—" + if hz >= 100: + return f"{hz:.0f} Hz" + return f"{hz:.1f} Hz" + + +def format_count(count: int) -> str: + """Format a frame/sample count with thousands separators.""" + return f"{count:,}" + + +def format_ns_ago(ns: Optional[int], now_ns: int) -> str: + """Format ``now_ns - ns`` as a human-readable 'Xms ago' string.""" + if ns is None: + return "—" + delta_ms = (now_ns - ns) / 1e6 + if delta_ms < 0: + delta_ms = 0.0 + if delta_ms < 1000: + return f"{delta_ms:.0f} ms ago" + if delta_ms < 60_000: + return f"{delta_ms / 1000:.1f} s ago" + return f"{delta_ms / 60_000:.1f} min ago" + + +def format_path_tail(path: str, max_chars: int = 60) -> str: + """Truncate a long path from the left so the tail (episode id) stays visible.""" + if len(path) <= max_chars: + return path + return "…" + path[-(max_chars - 1):] + + +def format_chirp_pair( + chirp_start_ns: Optional[int], + chirp_stop_ns: Optional[int], +) -> str: + """Format the chirp start/stop pair for the session clock panel.""" + if chirp_start_ns is None: + return "pending" + start_s = chirp_start_ns / 1e9 + if chirp_stop_ns is None: + return f"start @ {start_s:.3f}s" + span_ms = (chirp_stop_ns - chirp_start_ns) / 1e6 + return f"start + {span_ms:.0f} ms span" + + +def state_label(state_value: str) -> str: + """Uppercase a session state for the header chip.""" + return state_value.upper() if state_value else "" diff --git a/src/syncfield/viewer/widgets/layout.py b/src/syncfield/viewer/widgets/layout.py new file mode 100644 index 0000000..c867188 --- /dev/null +++ b/src/syncfield/viewer/widgets/layout.py @@ -0,0 +1,404 @@ +"""Top-level viewer layout. + +One :class:`ViewerLayout` instance owns all the DearPyGui tags for the +viewer window. It builds the UI once in :meth:`build`, then every render +frame the app calls :meth:`update` with the latest +:class:`SessionSnapshot` and the layout fans the values out to each +widget. Stream cards are created lazily as new stream ids appear in +snapshots. + +Sections (top to bottom): + + ┌── Header ──────────────────────────────── state · timer ─┐ + ├── Control panel │ Session clock + chirp ──────────────┤ + ├── Streams (horizontal card row) ─────────────────────────┤ + ├── Health timeline ───────────────────────────────────────┤ + └── Footer: output dir · sync point wall clock ────────────┘ +""" + +from __future__ import annotations + +import threading +import time +from typing import Dict, Optional, TYPE_CHECKING + +import dearpygui.dearpygui as dpg + +from syncfield.orchestrator import SessionOrchestrator +from syncfield.types import SessionState +from syncfield.viewer import theme +from syncfield.viewer.state import SessionSnapshot +from syncfield.viewer.widgets.formatting import ( + format_chirp_pair, + format_elapsed, + format_path_tail, + state_label, +) +from syncfield.viewer.widgets.stream_card import StreamCard + + +class ViewerLayout: + """Owns the DearPyGui nodes for every section of the viewer. + + The layout does **not** import :class:`~syncfield.viewer.app.ViewerApp` + directly — instead, callbacks capture the session and call its methods + from a worker thread so the render loop never blocks. + """ + + def __init__(self, session: SessionOrchestrator) -> None: + self._session = session + self._cards: Dict[str, StreamCard] = {} + self._streams_row_tag = "streams_row" + self._health_table_tag = "health_table" + self._last_health_keys: tuple = () + + # ------------------------------------------------------------------ + # Build (called once at viewer startup) + # ------------------------------------------------------------------ + + def build(self) -> None: + """Construct the main window and all static chrome.""" + with dpg.window( + tag="main_window", + no_title_bar=True, + no_move=True, + no_resize=True, + no_collapse=True, + no_bring_to_front_on_focus=True, + ): + self._build_header() + dpg.add_spacer(height=8) + self._build_control_and_clock_row() + dpg.add_spacer(height=8) + self._build_streams_section() + dpg.add_spacer(height=8) + self._build_health_section() + dpg.add_spacer(height=8) + self._build_footer() + + # Button themes need the context to exist, so build them now. + self._primary_theme = theme.build_primary_button_theme() + self._danger_theme = theme.build_danger_button_theme() + self._ghost_theme = theme.build_ghost_button_theme() + self._soft_panel_theme = theme.build_soft_panel_theme() + + dpg.bind_item_theme("control_panel", self._soft_panel_theme) + dpg.bind_item_theme("clock_panel", self._soft_panel_theme) + dpg.bind_item_theme("btn_record", self._primary_theme) + dpg.bind_item_theme("btn_stop", self._danger_theme) + dpg.bind_item_theme("btn_cancel", self._ghost_theme) + + # ------------------------------------------------------------------ + # Sections + # ------------------------------------------------------------------ + + def _build_header(self) -> None: + """Top row: logo, host id, state chip, elapsed timer.""" + with dpg.group(horizontal=True): + dpg.add_text("SyncField", tag="app_title") + dpg.add_spacer(width=12) + dpg.add_text("—", color=theme.TEXT_MUTED) + dpg.add_spacer(width=12) + dpg.add_text(self._session.host_id, tag="host_id_text") + dpg.add_spacer(width=20) + dpg.add_text("●", tag="state_dot", color=theme.STATE_IDLE) + dpg.add_spacer(width=4) + dpg.add_text("IDLE", tag="state_label", color=theme.TEXT_SECONDARY) + dpg.add_spacer(width=20) + dpg.add_text( + "00:00.000", + tag="elapsed_text", + color=theme.TEXT_SECONDARY, + ) + + dpg.add_spacer(height=4) + dpg.add_text( + "Capture orchestration — live session view", + color=theme.TEXT_MUTED, + ) + + def _build_control_and_clock_row(self) -> None: + """Two side-by-side panels: controls + session clock.""" + with dpg.group(horizontal=True): + # --- Control panel ---------------------------------------- + with dpg.child_window( + tag="control_panel", + width=260, + height=theme.CONTROL_PANEL_HEIGHT, + border=False, + no_scrollbar=True, + ): + dpg.add_text("CONTROLS", color=theme.TEXT_MUTED) + dpg.add_spacer(height=6) + with dpg.group(horizontal=True): + dpg.add_button( + label="● Record", + tag="btn_record", + width=110, + height=34, + callback=self._on_record_click, + ) + dpg.add_button( + label="■ Stop", + tag="btn_stop", + width=90, + height=34, + callback=self._on_stop_click, + ) + dpg.add_spacer(height=6) + dpg.add_button( + label="Cancel", + tag="btn_cancel", + width=206, + height=28, + callback=self._on_cancel_click, + ) + + dpg.add_spacer(width=12) + + # --- Session clock + chirp panel -------------------------- + with dpg.child_window( + tag="clock_panel", + width=-1, + height=theme.CONTROL_PANEL_HEIGHT, + border=False, + no_scrollbar=True, + ): + dpg.add_text("SESSION CLOCK", color=theme.TEXT_MUTED) + dpg.add_spacer(height=6) + with dpg.group(horizontal=True): + dpg.add_text("sync_point", color=theme.TEXT_SECONDARY) + dpg.add_spacer(width=8) + dpg.add_text("—", tag="sync_point_text") + with dpg.group(horizontal=True): + dpg.add_text("chirp", color=theme.TEXT_SECONDARY) + dpg.add_spacer(width=38) + dpg.add_text("pending", tag="chirp_text") + with dpg.group(horizontal=True): + dpg.add_text("tone", color=theme.TEXT_SECONDARY) + dpg.add_spacer(width=42) + dpg.add_text("—", tag="tone_text") + + def _build_streams_section(self) -> None: + """Horizontal scrollable row of stream cards.""" + dpg.add_text("STREAMS", color=theme.TEXT_MUTED) + dpg.add_spacer(height=4) + with dpg.child_window( + tag="streams_container", + width=-1, + height=theme.STREAMS_SECTION_HEIGHT, + border=False, + horizontal_scrollbar=True, + ): + with dpg.group(horizontal=True, tag=self._streams_row_tag): + pass # Cards added lazily in update() + + def _build_health_section(self) -> None: + """A table of recent health events.""" + dpg.add_text("HEALTH EVENTS", color=theme.TEXT_MUTED) + dpg.add_spacer(height=4) + with dpg.child_window( + width=-1, + height=theme.HEALTH_SECTION_HEIGHT, + border=False, + no_scrollbar=False, + ): + with dpg.table( + tag=self._health_table_tag, + header_row=True, + borders_innerH=True, + borders_outerH=False, + borders_innerV=False, + borders_outerV=False, + row_background=True, + scrollY=True, + height=theme.HEALTH_SECTION_HEIGHT - 24, + ): + dpg.add_table_column(label="Time", width_fixed=True, init_width_or_weight=90) + dpg.add_table_column(label="Stream", width_fixed=True, init_width_or_weight=140) + dpg.add_table_column(label="Kind", width_fixed=True, init_width_or_weight=120) + dpg.add_table_column(label="Detail") + + def _build_footer(self) -> None: + """Output path and wall clock.""" + with dpg.group(horizontal=True): + dpg.add_text("output", color=theme.TEXT_MUTED) + dpg.add_spacer(width=8) + dpg.add_text("—", tag="output_text", color=theme.TEXT_SECONDARY) + with dpg.group(horizontal=True): + dpg.add_text("wall clock", color=theme.TEXT_MUTED) + dpg.add_spacer(width=8) + dpg.add_text("—", tag="wall_clock_text", color=theme.TEXT_SECONDARY) + + # ------------------------------------------------------------------ + # Update (called every render frame) + # ------------------------------------------------------------------ + + def update(self, snapshot: SessionSnapshot) -> None: + """Sync every widget from the latest snapshot.""" + now_ns = time.monotonic_ns() + + self._update_header(snapshot) + self._update_clock_panel(snapshot) + self._update_controls(snapshot) + self._update_streams(snapshot, now_ns) + self._update_health(snapshot) + self._update_footer(snapshot) + + def _update_header(self, snapshot: SessionSnapshot) -> None: + dpg.configure_item("state_dot", color=theme.state_color(snapshot.state)) + dpg.set_value("state_label", state_label(snapshot.state)) + dpg.set_value("elapsed_text", format_elapsed(snapshot.elapsed_s)) + dpg.set_value("host_id_text", snapshot.host_id) + + def _update_clock_panel(self, snapshot: SessionSnapshot) -> None: + if snapshot.sync_point_monotonic_ns is not None: + sp_s = snapshot.sync_point_monotonic_ns / 1e9 + dpg.set_value("sync_point_text", f"{sp_s:,.3f}s (monotonic)") + else: + dpg.set_value("sync_point_text", "—") + + dpg.set_value( + "chirp_text", + format_chirp_pair(snapshot.chirp_start_ns, snapshot.chirp_stop_ns) + if snapshot.chirp_enabled + else "disabled (silent)", + ) + dpg.set_value( + "tone_text", + "400 → 2500 Hz, 500 ms" if snapshot.chirp_enabled else "—", + ) + + def _update_controls(self, snapshot: SessionSnapshot) -> None: + state = snapshot.state + # Enable/disable the three buttons based on the orchestrator state. + _set_enabled("btn_record", state == "idle") + _set_enabled("btn_stop", state == "recording") + _set_enabled("btn_cancel", state in ("preparing", "recording")) + + def _update_streams(self, snapshot: SessionSnapshot, now_ns: int) -> None: + # Create cards for new streams. + for stream_id, stream_snap in snapshot.streams.items(): + if stream_id not in self._cards: + self._cards[stream_id] = StreamCard(self._streams_row_tag, stream_snap) + self._cards[stream_id].update(stream_snap, now_ns) + + # Cards for streams that were removed (rare, but keep the UI honest). + removed = set(self._cards.keys()) - set(snapshot.streams.keys()) + for stream_id in removed: + card = self._cards.pop(stream_id) + try: + dpg.delete_item(card._card_tag) + except Exception: + pass + + def _update_health(self, snapshot: SessionSnapshot) -> None: + """Rebuild the health table when the event set changes. + + We avoid rebuilding every frame — instead compare a cheap key + (tuple of event at_ns + kind) and only touch DPG when the log + actually changes. This keeps the table scroll position stable + and avoids rapid row churn. + """ + key = tuple((ev.at_ns, ev.kind, ev.stream_id) for ev in snapshot.health_log) + if key == self._last_health_keys: + return + self._last_health_keys = key + + # Drop existing rows. + for child in dpg.get_item_children(self._health_table_tag, 1) or []: + dpg.delete_item(child) + + # Re-populate newest first. + for ev in reversed(snapshot.health_log): + with dpg.table_row(parent=self._health_table_tag): + dpg.add_text(_format_time_short(ev.at_ns), color=theme.TEXT_SECONDARY) + dpg.add_text(ev.stream_id) + dpg.add_text( + ev.kind.upper(), + color=_health_kind_color(ev.kind), + ) + dpg.add_text(ev.detail or "") + + def _update_footer(self, snapshot: SessionSnapshot) -> None: + dpg.set_value("output_text", format_path_tail(snapshot.output_dir)) + if snapshot.sync_point_wall_clock_ns is not None: + t = time.localtime(snapshot.sync_point_wall_clock_ns / 1e9) + dpg.set_value( + "wall_clock_text", + time.strftime("%Y-%m-%d %H:%M:%S", t), + ) + else: + dpg.set_value("wall_clock_text", "—") + + # ------------------------------------------------------------------ + # Button callbacks — all delegate to a worker thread so the UI stays + # responsive while the SDK's start()/stop() run. + # ------------------------------------------------------------------ + + def _on_record_click(self) -> None: + threading.Thread( + target=self._safe_call, + args=(self._session.start,), + name="viewer-ctrl-start", + daemon=True, + ).start() + + def _on_stop_click(self) -> None: + threading.Thread( + target=self._safe_call, + args=(self._session.stop,), + name="viewer-ctrl-stop", + daemon=True, + ).start() + + def _on_cancel_click(self) -> None: + """Cancel is SessionOrchestrator.stop() if recording — the SDK + has no dedicated cancel primitive, so we call stop() which takes + the best-effort path. Applications with richer cancellation can + subclass this layout in the future.""" + self._on_stop_click() + + @staticmethod + def _safe_call(fn) -> None: + try: + fn() + except Exception: + import logging + + logging.getLogger(__name__).exception( + "Viewer session control call failed" + ) + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + + +def _set_enabled(tag: str, enabled: bool) -> None: + try: + if enabled: + dpg.enable_item(tag) + else: + dpg.disable_item(tag) + except Exception: + pass + + +def _format_time_short(at_ns: int) -> str: + """Format a monotonic_ns timestamp as ``MM:SS.mmm`` for the table.""" + s = at_ns / 1e9 + minutes = int(s // 60) + remainder = s - minutes * 60 + return f"{minutes:02d}:{remainder:06.3f}" + + +def _health_kind_color(kind: str): + return { + "heartbeat": theme.TEXT_MUTED, + "drop": theme.WARNING, + "reconnect": theme.INFO, + "warning": theme.WARNING, + "error": theme.DANGER, + }.get(kind, theme.TEXT_SECONDARY) diff --git a/src/syncfield/viewer/widgets/stream_card.py b/src/syncfield/viewer/widgets/stream_card.py new file mode 100644 index 0000000..2bf8684 --- /dev/null +++ b/src/syncfield/viewer/widgets/stream_card.py @@ -0,0 +1,318 @@ +"""Per-stream card widgets. + +Each stream is rendered as a 260 x 300 card. The card's body varies by +stream kind: + +- **video** → live GPU texture fed from ``stream.latest_frame`` +- **sensor** → a small line plot of numeric channels (up to 6 series) +- everything else → a minimal stats block + +The card shell, header, and stats row are identical across variants so the +viewer looks consistent regardless of what kind of data the user registers. +""" + +from __future__ import annotations + +import time +from typing import Dict, List, Optional + +import dearpygui.dearpygui as dpg +import numpy as np + +from syncfield.viewer import theme +from syncfield.viewer.state import StreamSnapshot +from syncfield.viewer.widgets.formatting import ( + format_count, + format_hz, + format_ns_ago, +) + + +# Texture resolution for video previews. We keep this fixed so all cards +# share one preset; real frames are resized (with aspect-ratio letterboxing) +# into this buffer before upload. +PREVIEW_W = 260 +PREVIEW_H = theme.VIDEO_THUMBNAIL_HEIGHT + + +class StreamCard: + """Owns the DearPyGui nodes for one stream card. + + One instance per registered stream. Construction happens lazily the + first time the layout sees a given stream id in a snapshot, so dynamic + stream additions Just Work. + """ + + def __init__(self, parent_tag: str, snapshot: StreamSnapshot) -> None: + self._stream_id = snapshot.id + self._kind = snapshot.kind + self._card_tag = f"card::{snapshot.id}" + self._title_tag = f"card_title::{snapshot.id}" + self._state_dot_tag = f"card_dot::{snapshot.id}" + self._frame_count_tag = f"card_frames::{snapshot.id}" + self._hz_tag = f"card_hz::{snapshot.id}" + self._last_sample_tag = f"card_last::{snapshot.id}" + self._capability_tag = f"card_cap::{snapshot.id}" + + # Variant-specific tags (populated by the matching _build_body method) + self._texture_tag: Optional[str] = None + self._plot_tag: Optional[str] = None + self._plot_x_axis_tag: Optional[str] = None + self._plot_y_axis_tag: Optional[str] = None + self._series_tags: Dict[str, str] = {} + + self._build(parent_tag, snapshot) + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + def _build(self, parent_tag: str, snapshot: StreamSnapshot) -> None: + with dpg.child_window( + tag=self._card_tag, + parent=parent_tag, + width=theme.CARD_WIDTH, + height=theme.CARD_HEIGHT, + border=True, + no_scrollbar=True, + ): + dpg.bind_item_theme(self._card_tag, theme.build_card_theme()) + + # --- Header row: stream id + status dot ------------------- + with dpg.group(horizontal=True): + dpg.add_text(snapshot.id, tag=self._title_tag) + dpg.add_spacer(width=4) + dpg.add_text( + "●", + tag=self._state_dot_tag, + color=theme.SUCCESS, + ) + dpg.add_text( + _capability_label(snapshot), + tag=self._capability_tag, + color=theme.TEXT_SECONDARY, + ) + dpg.add_spacer(height=6) + + # --- Body: variant-specific -------------------------------- + if self._kind == "video": + self._build_video_body(snapshot) + elif self._kind in ("sensor", "audio"): + self._build_plot_body(snapshot) + else: + self._build_stats_body() + + dpg.add_spacer(height=6) + + # --- Footer stats row ------------------------------------- + with dpg.group(horizontal=True): + dpg.add_text( + format_count(snapshot.frame_count), + tag=self._frame_count_tag, + ) + dpg.add_text("frames", color=theme.TEXT_SECONDARY) + dpg.add_spacer(width=10) + dpg.add_text( + format_hz(snapshot.effective_hz), + tag=self._hz_tag, + color=theme.TEXT_SECONDARY, + ) + dpg.add_text( + "last sample: —", + tag=self._last_sample_tag, + color=theme.TEXT_MUTED, + ) + + def _build_video_body(self, snapshot: StreamSnapshot) -> None: + """A raw-texture image that the render loop updates in place.""" + self._texture_tag = f"texture::{snapshot.id}" + initial = np.zeros(PREVIEW_W * PREVIEW_H * 4, dtype=np.float32) + with dpg.texture_registry(show=False): + dpg.add_raw_texture( + width=PREVIEW_W, + height=PREVIEW_H, + default_value=initial, + format=dpg.mvFormat_Float_rgba, + tag=self._texture_tag, + ) + dpg.add_image( + self._texture_tag, + width=PREVIEW_W - 28, # account for card padding + height=PREVIEW_H, + ) + + def _build_plot_body(self, snapshot: StreamSnapshot) -> None: + """A line plot for numeric sensor channels.""" + self._plot_tag = f"plot::{snapshot.id}" + self._plot_x_axis_tag = f"plot_x::{snapshot.id}" + self._plot_y_axis_tag = f"plot_y::{snapshot.id}" + with dpg.plot( + tag=self._plot_tag, + height=theme.PLOT_HEIGHT, + width=-1, + no_title=True, + no_menus=True, + no_mouse_pos=True, + ): + dpg.add_plot_axis( + dpg.mvXAxis, tag=self._plot_x_axis_tag, no_tick_labels=True + ) + dpg.add_plot_axis( + dpg.mvYAxis, tag=self._plot_y_axis_tag, no_tick_labels=True + ) + + def _build_stats_body(self) -> None: + """Fallback body — a discreet placeholder for custom/opaque streams.""" + with dpg.group(): + dpg.add_text( + "no live preview", + color=theme.TEXT_MUTED, + ) + dpg.add_spacer(height=theme.VIDEO_THUMBNAIL_HEIGHT - 24) + + # ------------------------------------------------------------------ + # Update — called every render frame + # ------------------------------------------------------------------ + + def update(self, snapshot: StreamSnapshot, now_ns: int) -> None: + """Sync this card to the newest snapshot.""" + dpg.set_value(self._frame_count_tag, format_count(snapshot.frame_count)) + dpg.set_value(self._hz_tag, format_hz(snapshot.effective_hz)) + dpg.set_value( + self._last_sample_tag, + f"last sample: {format_ns_ago(snapshot.last_sample_at_ns, now_ns)}", + ) + dpg.configure_item( + self._state_dot_tag, + color=_dot_color(snapshot, now_ns), + ) + + if self._kind == "video": + self._update_video_texture(snapshot) + elif self._kind in ("sensor", "audio"): + self._update_plot(snapshot) + + def _update_video_texture(self, snapshot: StreamSnapshot) -> None: + """Upload the latest frame to the GPU texture, with letterboxing.""" + if self._texture_tag is None: + return + frame = snapshot.latest_frame + if frame is None: + return + try: + rgba = _fit_to_preview_rgba(frame, PREVIEW_W, PREVIEW_H) + except Exception: + # A single frame with an unexpected shape should never tear + # the whole card down. + return + dpg.set_value(self._texture_tag, rgba) + + def _update_plot(self, snapshot: StreamSnapshot) -> None: + """Update or create per-channel line series.""" + if self._plot_tag is None or self._plot_y_axis_tag is None: + return + for index, (channel_name, (xs, ys)) in enumerate(snapshot.plot_points.items()): + series_tag = self._series_tags.get(channel_name) + if series_tag is None: + series_tag = f"series::{self._stream_id}::{channel_name}" + dpg.add_line_series( + list(xs), + list(ys), + label=channel_name, + parent=self._plot_y_axis_tag, + tag=series_tag, + ) + # Apply a per-series color so multi-channel plots stay legible. + with dpg.theme() as series_theme: + with dpg.theme_component(dpg.mvLineSeries): + dpg.add_theme_color( + dpg.mvPlotCol_Line, + theme.series_color(index), + category=dpg.mvThemeCat_Plots, + ) + dpg.add_theme_style( + dpg.mvPlotStyleVar_LineWeight, + 1.8, + category=dpg.mvThemeCat_Plots, + ) + dpg.bind_item_theme(series_tag, series_theme) + self._series_tags[channel_name] = series_tag + else: + dpg.set_value(series_tag, [list(xs), list(ys)]) + + if snapshot.plot_points: + dpg.fit_axis_data(self._plot_x_axis_tag) # type: ignore[arg-type] + dpg.fit_axis_data(self._plot_y_axis_tag) + + +# --------------------------------------------------------------------------- +# Free helpers +# --------------------------------------------------------------------------- + + +def _capability_label(snapshot: StreamSnapshot) -> str: + tags: List[str] = [snapshot.kind] + if snapshot.provides_audio_track: + tags.append("audio") + if snapshot.produces_file: + tags.append("file") + return " · ".join(tags) + + +def _dot_color(snapshot: StreamSnapshot, now_ns: int): + """Pick a dot color based on freshness and health counts.""" + if snapshot.health_count > 0: + return theme.WARNING + last = snapshot.last_sample_at_ns + if last is None: + return theme.TEXT_MUTED + if now_ns - last > 1_500_000_000: # 1.5s stale + return theme.WARNING + return theme.SUCCESS + + +def _fit_to_preview_rgba(frame: np.ndarray, target_w: int, target_h: int) -> np.ndarray: + """Convert an arbitrary BGR/RGB frame into a letterboxed RGBA float32 buffer. + + Uses simple NumPy slicing instead of cv2 so the viewer doesn't require + opencv-python. That lets users install ``syncfield[viewer]`` without + also needing the ``uvc`` extra. + """ + if frame.ndim != 3 or frame.shape[2] < 3: + raise ValueError(f"unexpected frame shape {frame.shape}") + + src_h, src_w = frame.shape[0], frame.shape[1] + if src_h == 0 or src_w == 0: + raise ValueError("empty frame") + + # Fit-within (letterbox) into target while preserving aspect ratio. + scale = min(target_w / src_w, target_h / src_h) + new_w = max(1, int(src_w * scale)) + new_h = max(1, int(src_h * scale)) + + # Nearest-neighbor resize — cheap, no external deps. The viewer only + # needs a thumbnail; interpolation quality isn't critical. + ys = (np.linspace(0, src_h - 1, new_h)).astype(np.int32) + xs = (np.linspace(0, src_w - 1, new_w)).astype(np.int32) + resized = frame[ys][:, xs] + + # OAK frames come out as BGR (DepthAI) and so do UVC frames (OpenCV). + # Swap to RGB so the preview colors match reality. + if resized.shape[2] >= 3: + resized = resized[:, :, [2, 1, 0]] + + # Letterbox into the full target buffer. + canvas = np.full( + (target_h, target_w, 3), + fill_value=240, # near-white letterbox matches the light theme + dtype=np.uint8, + ) + y_off = (target_h - new_h) // 2 + x_off = (target_w - new_w) // 2 + canvas[y_off : y_off + new_h, x_off : x_off + new_w] = resized[:, :, :3] + + # Convert to RGBA float32 in [0, 1] — DPG mvFormat_Float_rgba. + rgba = np.empty((target_h, target_w, 4), dtype=np.float32) + rgba[:, :, :3] = canvas.astype(np.float32) / 255.0 + rgba[:, :, 3] = 1.0 + return rgba.flatten() diff --git a/tests/unit/viewer/__init__.py b/tests/unit/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/viewer/test_formatting.py b/tests/unit/viewer/test_formatting.py new file mode 100644 index 0000000..7f5b98a --- /dev/null +++ b/tests/unit/viewer/test_formatting.py @@ -0,0 +1,117 @@ +"""Unit tests for the viewer's formatting helpers. + +These tests exercise pure-Python logic that has no DearPyGui dependency, +so they run in any environment. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("dearpygui.dearpygui") + +from syncfield.viewer.widgets.formatting import ( + format_chirp_pair, + format_count, + format_elapsed, + format_hz, + format_ns_ago, + format_path_tail, + state_label, +) + + +class TestFormatElapsed: + def test_zero(self): + assert format_elapsed(0) == "00:00.000" + + def test_sub_second(self): + assert format_elapsed(0.123) == "00:00.123" + + def test_full_minutes(self): + assert format_elapsed(65.5) == "01:05.500" + + def test_large(self): + assert format_elapsed(600.001) == "10:00.001" + + def test_negative_clamped_to_zero(self): + assert format_elapsed(-5.0) == "00:00.000" + + def test_millisecond_overflow_clamped(self): + """Floating point rounding to 1000 must not produce '00.1000'.""" + # 59.9995 rounds to 1000 ms → must clamp to 999 + value = format_elapsed(59.9999999) + minutes, rest = value.split(":") + assert len(rest) == 6 # "SS.mmm" + assert int(rest.split(".")[1]) <= 999 + + +class TestFormatHz: + def test_zero(self): + assert format_hz(0) == "—" + + def test_low(self): + assert format_hz(29.9) == "29.9 Hz" + + def test_high(self): + assert format_hz(100.7) == "101 Hz" + + +class TestFormatCount: + def test_small(self): + assert format_count(5) == "5" + + def test_thousands(self): + assert format_count(1234) == "1,234" + + def test_millions(self): + assert format_count(1_234_567) == "1,234,567" + + +class TestFormatNsAgo: + def test_none(self): + assert format_ns_ago(None, 0) == "—" + + def test_millis(self): + assert format_ns_ago(0, 500_000_000) == "500 ms ago" + + def test_seconds(self): + assert format_ns_ago(0, 2_500_000_000) == "2.5 s ago" + + def test_minutes(self): + assert format_ns_ago(0, 120_000_000_000).endswith("min ago") + + def test_never_negative(self): + assert format_ns_ago(100, 50) == "0 ms ago" + + +class TestFormatPathTail: + def test_short_path_unchanged(self): + path = "/tmp/data" + assert format_path_tail(path) == path + + def test_long_path_truncated_from_left(self): + path = "/" + "a" * 100 + out = format_path_tail(path, max_chars=20) + assert out.startswith("…") + assert len(out) == 20 + + +class TestFormatChirpPair: + def test_pending(self): + assert format_chirp_pair(None, None) == "pending" + + def test_start_only(self): + assert format_chirp_pair(1_234_000_000, None).startswith("start @ ") + + def test_start_and_stop(self): + out = format_chirp_pair(1_000_000_000, 1_500_000_000) + assert "500 ms" in out + + +class TestStateLabel: + def test_empty(self): + assert state_label("") == "" + + def test_uppercase(self): + assert state_label("recording") == "RECORDING" diff --git a/tests/unit/viewer/test_poller.py b/tests/unit/viewer/test_poller.py new file mode 100644 index 0000000..a96943e --- /dev/null +++ b/tests/unit/viewer/test_poller.py @@ -0,0 +1,117 @@ +"""Integration-ish tests for the poller against a real SessionOrchestrator. + +We can't reasonably unit-test the full DPG app in CI, but the poller is a +pure Python object that just reads session attributes, so we can exercise +it end-to-end with FakeStreams and verify that snapshots match reality. +""" + +from __future__ import annotations + +import time + +import pytest + +pytest.importorskip("dearpygui.dearpygui") + +import syncfield as sf +from syncfield.testing import FakeStream +from syncfield.types import HealthEventKind +from syncfield.viewer.poller import SessionPoller + + +def _make_session(tmp_path): + session = sf.SessionOrchestrator( + host_id="test_rig", + output_dir=tmp_path, + sync_tone=sf.SyncToneConfig.silent(), + ) + session.add(FakeStream("cam", provides_audio_track=True)) + session.add(FakeStream("imu")) + return session + + +class TestSnapshotBuilding: + def test_idle_snapshot_has_zero_elapsed(self, tmp_path): + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + snap = poller._build_snapshot() + + assert snap.host_id == "test_rig" + assert snap.state == "idle" + assert snap.elapsed_s == 0.0 + assert snap.sync_point_monotonic_ns is None + assert snap.chirp_start_ns is None + assert set(snap.streams.keys()) == {"cam", "imu"} + + def test_recording_snapshot_populates_sync_point(self, tmp_path): + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + + session.start() + try: + snap = poller._build_snapshot() + assert snap.state == "recording" + assert snap.sync_point_monotonic_ns is not None + assert snap.sync_point_wall_clock_ns is not None + finally: + session.stop() + + def test_stream_capabilities_propagate(self, tmp_path): + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + snap = poller._build_snapshot() + + assert snap.streams["cam"].provides_audio_track is True + assert snap.streams["imu"].provides_audio_track is False + + def test_push_sample_shows_up_in_next_snapshot(self, tmp_path): + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + + # Register callbacks (normally done by start(), but we can call + # directly for tests). + poller._register_callbacks() + + session.start() + try: + imu = session._streams["imu"] # type: ignore[attr-defined] + for i in range(10): + imu.push_sample(frame_number=i, capture_ns=time.monotonic_ns()) + snap = poller._build_snapshot() + assert snap.streams["imu"].frame_count == 10 + assert snap.streams["imu"].last_sample_at_ns is not None + finally: + session.stop() + + def test_push_health_surfaces_in_health_log(self, tmp_path): + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + poller._register_callbacks() + + session.start() + try: + imu = session._streams["imu"] # type: ignore[attr-defined] + imu.push_health(HealthEventKind.WARNING, at_ns=123, detail="burst") + snap = poller._build_snapshot() + assert len(snap.health_log) >= 1 + assert any( + ev.kind == "warning" and ev.detail == "burst" + for ev in snap.health_log + ) + assert snap.streams["imu"].health_count >= 1 + finally: + session.stop() + + def test_start_stop_and_get_snapshot_thread(self, tmp_path): + """End-to-end smoke test of the polling thread.""" + session = _make_session(tmp_path) + poller = SessionPoller(session, interval_s=0.01) + poller.start() + try: + # Give the poller a few ticks to populate snapshot + time.sleep(0.05) + snap = poller.get_snapshot() + assert snap is not None + assert snap.host_id == "test_rig" + finally: + poller.stop() diff --git a/tests/unit/viewer/test_state.py b/tests/unit/viewer/test_state.py new file mode 100644 index 0000000..356109b --- /dev/null +++ b/tests/unit/viewer/test_state.py @@ -0,0 +1,123 @@ +"""Unit tests for the viewer's snapshot/state helpers. + +The poller's StreamStatsBuffer is the trickiest piece — it has to handle +channels that appear mid-stream without throwing off alignment, and its +fps rolling window must respect the 1-second cutoff. +""" + +from __future__ import annotations + +import math + +import pytest + +pytest.importorskip("dearpygui.dearpygui") + +from syncfield.viewer.state import HealthEntry, StreamStatsBuffer + + +class TestStreamStatsBufferSamples: + def test_empty_buffer_reports_zero_fps(self): + buf = StreamStatsBuffer() + assert buf.snapshot_fps(now_ns=1_000_000_000) == 0.0 + assert buf.snapshot_plot() == {} + + def test_samples_within_window_count_toward_fps(self): + buf = StreamStatsBuffer() + now = 2_000_000_000 # 2s + # 5 samples in the last second + for i in range(5): + buf.observe_sample(now - (i * 100_000_000), channels=None) + assert buf.snapshot_fps(now) == 5.0 + + def test_samples_outside_window_excluded(self): + buf = StreamStatsBuffer() + now = 5_000_000_000 # 5s + # 3 very old samples + 2 recent + for i in range(3): + buf.observe_sample(1_000_000_000 + i, channels=None) + buf.observe_sample(4_500_000_000, channels=None) + buf.observe_sample(4_900_000_000, channels=None) + assert buf.snapshot_fps(now) == 2.0 + + def test_plot_buffers_one_channel(self): + buf = StreamStatsBuffer() + buf.observe_sample(1_000_000_000, channels={"ax": 1.0}) + buf.observe_sample(2_000_000_000, channels={"ax": 2.0}) + plot = buf.snapshot_plot() + assert "ax" in plot + xs, ys = plot["ax"] + assert len(xs) == 2 + assert ys == [1.0, 2.0] + + def test_plot_backfills_nan_for_late_joining_channel(self): + """A channel that appears mid-stream should get NaN padding so + x/y arrays stay aligned in the plot.""" + buf = StreamStatsBuffer() + buf.observe_sample(1_000_000_000, channels={"ax": 1.0}) + buf.observe_sample(2_000_000_000, channels={"ax": 2.0}) + # New channel appears on tick 3 — should be left-padded + buf.observe_sample(3_000_000_000, channels={"ax": 3.0, "gx": 0.1}) + plot = buf.snapshot_plot() + assert "gx" in plot + gx_ys = plot["gx"][1] + assert len(gx_ys) == 3 + assert math.isnan(gx_ys[0]) + assert math.isnan(gx_ys[1]) + assert gx_ys[2] == 0.1 + + def test_plot_ignores_non_numeric_channels(self): + buf = StreamStatsBuffer() + buf.observe_sample(1, channels={"tag": "hello", "ax": 1.0, "nested": [1, 2]}) + plot = buf.snapshot_plot() + assert "ax" in plot + assert "tag" not in plot + assert "nested" not in plot + + def test_plot_none_channels(self): + buf = StreamStatsBuffer() + buf.observe_sample(1, channels=None) + buf.observe_sample(2, channels=None) + assert buf.snapshot_plot() == {} + + def test_missing_channel_fills_nan_forward(self): + """If ax was present on earlier samples but absent on a later one, + the later sample should inject a NaN so alignment is preserved.""" + buf = StreamStatsBuffer() + buf.observe_sample(1, channels={"ax": 1.0, "gx": 2.0}) + buf.observe_sample(2, channels={"ax": 3.0}) # gx missing + plot = buf.snapshot_plot() + ax_ys = plot["ax"][1] + gx_ys = plot["gx"][1] + assert ax_ys == [1.0, 3.0] + assert len(gx_ys) == 2 + assert gx_ys[0] == 2.0 + assert math.isnan(gx_ys[1]) + + +class TestStreamStatsBufferHealth: + def test_empty(self): + assert StreamStatsBuffer().snapshot_health() == [] + + def test_records_in_order(self): + buf = StreamStatsBuffer() + a = HealthEntry(stream_id="s", kind="warning", at_ns=10, detail="a") + b = HealthEntry(stream_id="s", kind="error", at_ns=20, detail="b") + buf.observe_health(a) + buf.observe_health(b) + events = buf.snapshot_health() + assert events == [a, b] + + def test_capped(self): + buf = StreamStatsBuffer(max_health=3) + # Override the internal deque size — real constructor caps at 20, + # but we test the cap principle with whatever the dataclass set up. + # The deque default is maxlen=20; this test verifies behaviour at + # whatever cap the buffer ended up with. + for i in range(30): + buf.observe_health( + HealthEntry(stream_id="s", kind="heartbeat", at_ns=i, detail=None) + ) + events = buf.snapshot_health() + assert len(events) <= 20 # matches default max_health + assert events[-1].at_ns == 29 # newest kept diff --git a/uv.lock b/uv.lock index eef2071..9bf167f 100644 --- a/uv.lock +++ b/uv.lock @@ -259,6 +259,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/0e/1f818f5dad75b806e1e65586e5380bec64565caf7caeee7047dfd5ff8c3d/dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:92b3aaea0e6df4cf83208ae994b08554335166eff726947733b93da748eab641", size = 886837, upload-time = "2026-04-02T04:50:45.644Z" }, ] +[[package]] +name = "dearpygui" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/71/114626e9b77b07b2d5d92e0030b00b4a78e73de1212cbe63656af3da636e/dearpygui-2.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:9805b99abcdf89b18c6877cfd4865f844398e1c555316d2f7347b1e8e62f29fd", size = 1931334, upload-time = "2026-02-17T14:21:51.362Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/dbd692d64a27c94d7bf4f05b87a4bd74bcd61699248a7fb1166635cef17a/dearpygui-2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:8b42ebd0a73ddf03ab5fb0777636216035716089ae449f904fe37ccebbed0061", size = 2592856, upload-time = "2026-02-17T14:22:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/58/e0/4be23bd80453b5ee216319a1f2005b57a7c25d00872056f7a96a0a21ef4e/dearpygui-2.2-cp310-cp310-win_amd64.whl", hash = "sha256:9872af7c4d1c7f8b4f1031c1c333ff83c778332674ac3d54178fa7ca0230c6ab", size = 1830505, upload-time = "2026-02-17T14:21:40.74Z" }, + { url = "https://files.pythonhosted.org/packages/b7/80/c62a26549688a9a2251fede8c1ba10f5e41964a4bb97dba486bcb1e0be28/dearpygui-2.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a2dbbd975e1dbdf4688ef49b95651192b6417c8722e470b9ad2b7f5029555c63", size = 1931280, upload-time = "2026-02-17T14:21:52.98Z" }, + { url = "https://files.pythonhosted.org/packages/01/a1/6c40624fcaa0ea429aa2b6906b19c639175de0677b2af52f00c2794a56ce/dearpygui-2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:87c16bc00b94ee748c8c156c10f353b7f0b6e843ecec54121cb3b9f254abf940", size = 2592871, upload-time = "2026-02-17T14:22:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/3683b74526a869403ca63bac33c47c8d1bbabe57d186eb33490b5d18459a/dearpygui-2.2-cp311-cp311-win_amd64.whl", hash = "sha256:d5a38e58a03a41e09915f9b026759899d772d32e920bcd114d1b3f344946e0f0", size = 1830497, upload-time = "2026-02-17T14:21:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" }, + { url = "https://files.pythonhosted.org/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/18/ab/eb8070ca8fd881d4a9ac49fca5fb7b54ce66cc2742afa38e59d72b2c2dec/dearpygui-2.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:084c309c56d3e05fcf75eef872df6df97f5e3e19da5ecad393a57cf7a5e56294", size = 1931423, upload-time = "2026-02-17T14:21:56.397Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/5988d5f4cf3ddc7c3d886623bb904b76c5f5f628a0256ac53d848df33cf7/dearpygui-2.2-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:05d8c18a0134d72f680e333c80ccab264351170293f86a05f5a0e14222992f27", size = 2592542, upload-time = "2026-02-17T14:22:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5a/573df5f7277a13b5044daa9a27797fbd4e766da03cab6462a151b557727c/dearpygui-2.2-cp313-cp313-win_amd64.whl", hash = "sha256:500087e88d61b4ef0c841f30b12a05f5128774db3883fde7ff7c6172f03f6d79", size = 1830558, upload-time = "2026-02-17T14:21:44.551Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/3ccaec465021b647f13c83be42a635043a08255076984a658ed691701498/dearpygui-2.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:22451146968729429ba37afa2602957dfefc03ff92dcc627dd4d85ba3f93e771", size = 1931385, upload-time = "2026-02-17T14:21:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/52/ac/8e591f33a712563742fe77b0731c1c900fe2fcc3d3e75bd4c7d8e60057a8/dearpygui-2.2-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:dcc9377d8d9fe27f659ae6b016fe96aa37d8b26b57ce60c47985290e1be7801e", size = 2592691, upload-time = "2026-02-17T14:22:05.191Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/d2/10/41035b530b4d6968a0860f625db42928387138d98db31e86112fc177098f/dearpygui-2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:9be4381cd4fcf9dab0e8eb7a11455296fb804875fb756c9a4c0aef59a8aabc12", size = 2592939, upload-time = "2026-02-17T14:22:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e0/64d0e0adc4acb0cf0863e19160de21b7779ff003b35ab34795bf1bf31773/dearpygui-2.2-cp39-cp39-win_amd64.whl", hash = "sha256:c16607014d3dbb8537b636fcf86b0282d2177a4ac2154bf0ac24cdddab82279a", size = 1830462, upload-time = "2026-02-17T14:21:48.723Z" }, +] + [[package]] name = "depthai" version = "3.5.0" @@ -842,6 +866,7 @@ source = { editable = "." } all = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "dearpygui" }, { name = "depthai" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, @@ -865,6 +890,12 @@ oak = [ uvc = [ { name = "opencv-python" }, ] +viewer = [ + { name = "dearpygui" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] [package.dev-dependencies] dev = [ @@ -877,16 +908,19 @@ dev = [ requires-dist = [ { name = "bleak", marker = "extra == 'all'", specifier = ">=0.21" }, { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.21" }, + { name = "dearpygui", marker = "extra == 'all'", specifier = ">=2.0" }, + { name = "dearpygui", marker = "extra == 'viewer'", specifier = ">=2.0" }, { name = "depthai", marker = "extra == 'all'", specifier = ">=3.0.0" }, { name = "depthai", marker = "extra == 'oak'", specifier = ">=3.0.0" }, { name = "numpy", marker = "extra == 'all'", specifier = ">=1.21" }, { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.21" }, + { name = "numpy", marker = "extra == 'viewer'", specifier = ">=1.21" }, { name = "opencv-python", marker = "extra == 'all'", specifier = ">=4.5" }, { name = "opencv-python", marker = "extra == 'uvc'", specifier = ">=4.5" }, { name = "sounddevice", marker = "extra == 'all'", specifier = ">=0.4.6" }, { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, ] -provides-extras = ["audio", "uvc", "ble", "oak", "all"] +provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "all"] [package.metadata.requires-dev] dev = [ From b01799bf63ecae8e93f105cea200d639fe2c36b5 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 09:35:42 -0700 Subject: [PATCH 03/45] feat(viewer): polish layout + chirp-enabled demo + screenshot harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the initial viewer commit — fixes layout clipping and enables the screenshot harness that produces the docs images. Layout tweaks ------------- - VIEWPORT_WIDTH 1280 → 1200, VIEWPORT_HEIGHT 860 → 900 — tighter horizontal margin, more room for the health events table. - CONTROL_PANEL_HEIGHT 110 → 160 — fits all three buttons (Record, Stop, Cancel) without clipping the ghost-styled Cancel. Demo: chirp enabled by default ------------------------------ The demo previously used SyncToneConfig.silent() to avoid beeping during runs. That made the session clock panel show "chirp: disabled" which misrepresented the actual SDK default. New approach: session = sf.SessionOrchestrator( host_id="demo_rig", output_dir=output_dir, sync_tone=sf.SyncToneConfig.default(), # chirp enabled chirp_player=SilentChirpPlayer(), # ...but silent ) cam_ego now also declares provides_audio_track=True so chirp eligibility kicks in and the sync point actually fills in chirp_start_ns. The viewer's session clock panel shows the real "400 → 2500 Hz, 500 ms" tone spec and the live chirp timestamps. Screenshot harness ------------------ demo.py gets a --screenshot PATH flag that: 1. Pins the viewport to (60, 60) via a new ViewerApp.viewport_pos kwarg 2. Runs for --duration seconds (default 3) 3. Probes the frontmost window title via AppleScript (sanity check) 4. Captures full-screen + viewport-region PNGs via `screencapture` 5. Quits cleanly Usage: python -m syncfield.viewer.demo --screenshot idle.png python -m syncfield.viewer.demo --auto-record --screenshot recording.png Used to generate the docs screenshots at website/static/img/viewer/. Keeping the harness in demo.py (instead of a separate script) means the docs images stay reproducible — future maintainers can rerun the same command to refresh them. All 179 tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/viewer/app.py | 11 +++ src/syncfield/viewer/demo.py | 140 +++++++++++++++++++++++++++++++--- src/syncfield/viewer/theme.py | 6 +- 3 files changed, 142 insertions(+), 15 deletions(-) diff --git a/src/syncfield/viewer/app.py b/src/syncfield/viewer/app.py index 08495fe..c6652cb 100644 --- a/src/syncfield/viewer/app.py +++ b/src/syncfield/viewer/app.py @@ -155,9 +155,11 @@ def __init__( session: SessionOrchestrator, *, title: str = "SyncField", + viewport_pos: Optional[tuple] = None, ) -> None: self._session = session self._title = title + self._viewport_pos = viewport_pos self._poller = SessionPoller(session) self._layout: Optional[ViewerLayout] = None self._running = False @@ -200,6 +202,15 @@ def setup(self) -> None: dpg.setup_dearpygui() dpg.show_viewport() + # Pin the viewport to a specific on-screen position when the caller + # supplies one (used by the screenshot harness to place the window + # at a known coordinate). + if self._viewport_pos is not None: + try: + dpg.set_viewport_pos(self._viewport_pos) + except Exception: + pass + # Make the primary window fill the viewport so resizing feels # native. The layout's main window is tagged "main_window". dpg.set_primary_window("main_window", True) diff --git a/src/syncfield/viewer/demo.py b/src/syncfield/viewer/demo.py index 388c31f..d14e175 100644 --- a/src/syncfield/viewer/demo.py +++ b/src/syncfield/viewer/demo.py @@ -58,12 +58,13 @@ def __init__( height: int = 360, fps: float = 30.0, hue_shift: float = 0.0, + provides_audio_track: bool = False, ) -> None: super().__init__( id=id, kind="video", capabilities=StreamCapabilities( - provides_audio_track=False, + provides_audio_track=provides_audio_track, supports_precise_timestamps=True, is_removable=False, produces_file=True, @@ -259,15 +260,36 @@ def _loop(self) -> None: def build_demo_session(output_dir: Path) -> sf.SessionOrchestrator: - """Construct a realistic multi-stream session for the viewer demo.""" + """Construct a realistic multi-stream session for the viewer demo. + + Chirp is **enabled** with the egonaut production defaults so the viewer + shows the real "sync tone active" UI state. A :class:`SilentChirpPlayer` + is injected so the demo never actually emits audio — great for running + the demo on a laptop or for capturing docs screenshots without beeping. + """ + from syncfield.tone import SilentChirpPlayer + session = sf.SessionOrchestrator( host_id="demo_rig", output_dir=output_dir, - sync_tone=sf.SyncToneConfig.silent(), # don't actually play audio in the demo + sync_tone=sf.SyncToneConfig.default(), # chirp enabled by default + chirp_player=SilentChirpPlayer(), # ...but don't actually beep + ) + # Mark at least one stream as audio-capable so the orchestrator decides + # chirp is eligible and fills in chirp_start_ns / chirp_stop_ns in the + # sync point — without that, the viewer's "chirp" line would read + # "pending" forever. + session.add( + SyntheticVideoStream( + "cam_ego", width=640, height=360, hue_shift=0.0, + provides_audio_track=True, + ) ) - session.add(SyntheticVideoStream("cam_ego", width=640, height=360, hue_shift=0.0)) session.add( - SyntheticVideoStream("cam_wrist_left", width=480, height=480, hue_shift=1.7) + SyntheticVideoStream( + "cam_wrist_left", width=480, height=480, hue_shift=1.7, + provides_audio_track=False, + ) ) session.add(SyntheticImuStream("torso_imu")) session.add(FakeStream("tactile_left", provides_audio_track=False)) @@ -301,13 +323,26 @@ def main(argv: Optional[List[str]] = None) -> int: "Useful for screenshotting." ), ) + parser.add_argument( + "--screenshot", + type=Path, + default=None, + help=( + "Path to save a PNG screenshot of the viewer. Implies " + "--auto-record. The viewer runs for --duration seconds, waits " + "until the streams have warmed up, then captures the viewport " + "via dpg.output_frame_buffer() and exits." + ), + ) args = parser.parse_args(argv) + if args.screenshot is not None and args.duration <= 0: + # A screenshot run needs a bounded duration; default to 3s. + args.duration = 3.0 + args.output_dir.mkdir(parents=True, exist_ok=True) session = build_demo_session(args.output_dir) - import syncfield.viewer as viewer - if args.auto_record: # Start the session immediately so screenshots look populated. def _auto_record() -> None: @@ -319,14 +354,85 @@ def _auto_record() -> None: threading.Thread(target=_auto_record, daemon=True).start() - if args.duration > 0: - # Auto-close mode: run the viewer on the main thread for the - # requested duration, then stop. The viewer's event loop doesn't - # block on os.exit, so we set a timer that calls dpg.stop_dearpygui. + if args.duration > 0 or args.screenshot is not None: import dearpygui.dearpygui as dpg + import subprocess + + def _capture_window_screenshot() -> None: + """Capture the viewer window via AppleScript window discovery. + + On macOS 'screencapture -l ' grabs a specific window + by its CoreGraphics window id. The id is resolved via + AppleScript by matching the frontmost process's window title + against 'SyncField'. This avoids manual coordinate math and + Retina scaling entirely. + """ + args.screenshot.parent.mkdir(parents=True, exist_ok=True) + + # Step 1: ask macOS for the window id of the 'SyncField' window + osa = """ + tell application "System Events" + set frontApp to first application process whose frontmost is true + set frontWin to window 1 of frontApp + return value of attribute "AXTitle" of frontWin + end tell + """ + try: + result = subprocess.run( + ["osascript", "-e", osa], + capture_output=True, + text=True, + check=True, + timeout=5, + ) + print(f"frontmost window title: {result.stdout.strip()!r}", file=sys.stderr) + except Exception as exc: + print(f"title probe failed: {exc}", file=sys.stderr) + + # Step 2: capture the full screen then we can inspect it. If the + # full-screen dump looks right we'll refine to a window capture. + full_path = args.screenshot.with_suffix(".full.png") + try: + subprocess.run( + ["screencapture", "-x", "-t", "png", str(full_path)], + check=True, + timeout=5, + ) + print(f"full-screen dump → {full_path}", file=sys.stderr) + except Exception as exc: + print(f"full-screen capture failed: {exc}", file=sys.stderr) + + # Step 3: also try the interactive window capture by sending a + # key-like instruction to screencapture's -W mode. That's not + # scriptable; fall back to capturing a point-sized region. + try: + from syncfield.viewer import theme + + x, y = 60, 60 + w, h = theme.VIEWPORT_WIDTH, theme.VIEWPORT_HEIGHT + subprocess.run( + [ + "screencapture", + "-x", + "-t", + "png", + "-R", + f"{x},{y},{w},{h}", + str(args.screenshot), + ], + check=True, + timeout=5, + ) + print(f"region dump → {args.screenshot}", file=sys.stderr) + except Exception as exc: + print(f"region capture failed: {exc}", file=sys.stderr) def _timer() -> None: + # Extra settling time — DPG viewport move is async and the + # first few frames can show a flash of the default dark theme. time.sleep(args.duration) + if args.screenshot is not None: + _capture_window_screenshot() try: dpg.stop_dearpygui() except Exception: @@ -334,7 +440,17 @@ def _timer() -> None: threading.Thread(target=_timer, daemon=True).start() - viewer.launch(session) + # Pin the viewport so the screenshot helper knows where to look. + pin_pos = (60, 60) if args.screenshot is not None else None + + from syncfield.viewer.app import ViewerApp + + app = ViewerApp(session, title="SyncField", viewport_pos=pin_pos) + try: + app.setup() + app.run() + finally: + app.close() return 0 diff --git a/src/syncfield/viewer/theme.py b/src/syncfield/viewer/theme.py index c6805af..442a719 100644 --- a/src/syncfield/viewer/theme.py +++ b/src/syncfield/viewer/theme.py @@ -110,13 +110,13 @@ # Layout sections HEADER_HEIGHT = 72 -CONTROL_PANEL_HEIGHT = 110 +CONTROL_PANEL_HEIGHT = 160 STREAMS_SECTION_HEIGHT = 340 HEALTH_SECTION_HEIGHT = 180 FOOTER_HEIGHT = 48 -VIEWPORT_WIDTH = 1280 -VIEWPORT_HEIGHT = 860 +VIEWPORT_WIDTH = 1200 +VIEWPORT_HEIGHT = 900 # --------------------------------------------------------------------------- From 2f99ea8b0d4e81b5d69cbf3092f21eb50dd2239f Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 09:57:23 -0700 Subject: [PATCH 04/45] feat(adapters): add OgloTactileStream SDK reference adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the OGLO tactile glove BLE protocol from the egonaut iOS app (TactileGloveManager.swift) into a StreamBase-based SDK adapter, and lazy-exports it from syncfield.adapters. Mirrors the recorder-side OgloTactileSensor we added earlier — same protocol, same per-sample device_timestamp_ns interpolation — but lives in the SDK so syncfield.viewer / SessionOrchestrator / any other top-level orchestrator can drive it directly without going through the recorder's BaseSensor. Protocol -------- - Service UUID: 4652535f-424c-4500-0000-000000000001 - Notify char: 4652535f-424c-4500-0001-000000000001 - Config char: 4652535f-424c-4500-0002-000000000001 - Packet layout (little-endian): [0:2] u16 count — samples in this batch [2:6] u32 timestamp_us — MCU hardware clock at batch start [6:..] 5×u16 per sample: thumb, index, middle, ring, pinky - Sample rate: 100 Hz effective (10 notifications/sec × 10 samples each) - Scan filter: advertised name substring "oglo" (case-insensitive) One SampleEvent per decoded sample — the 10-sample batch fans out into 10 events so downstream consumers see the full 100 Hz rate. The MCU hardware clock is linearly interpolated across the batch (sample_i → batch_timestamp + i × 10 ms) so device_timestamp_ns is uniformly spaced at exactly 10_000_000 ns increments. Malformed packets become WARNING health events instead of tearing down the stream. API --- from syncfield.adapters import OgloTactileStream # Explicit address — preferred once you know which glove is which session.add(OgloTactileStream( id="tactile_right", address="AA:BB:CC:DD:EE:FF", hand="right", )) # Or scan by advertised name session.add(OgloTactileStream( id="tactile_right", ble_name="oglo", hand="right", )) Thread model matches BLEImuGenericStream: asyncio loop on an internal background thread, start/stop signaled via threading.Event, decode path factored into a pure _handle_payload method with a _dispatch_notification_for_test hook for unit tests. Gated behind syncfield[ble] (already pulls in bleak for BLEImuGenericStream). Listed in the adapters table in adapters/__init__.py docstring. Tests ----- tests/unit/adapters/test_oglo_tactile.py — 14 tests covering: - capability flags round-trip, hand property, default scan filter - UUID constants match the egonaut Swift values exactly - canonical finger order (thumb/index/middle/ring/pinky) - full batch decode with channel naming - device_timestamp_ns linear interpolation (10 ms spacing) - frame count accumulation across multiple batches - short packet / truncated body → WARNING health events (no crash) - FinalizationReport counts on stop - construction ValueError when both address and ble_name missing - ImportError with install hint when bleak is unavailable - Lazy re-export appears in syncfield.adapters.__all__ Full SDK suite: 193 passing (was 179, +14 new). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/__init__.py | 7 + src/syncfield/adapters/oglo_tactile.py | 333 +++++++++++++++++++++++ tests/unit/adapters/test_oglo_tactile.py | 233 ++++++++++++++++ 3 files changed, 573 insertions(+) create mode 100644 src/syncfield/adapters/oglo_tactile.py create mode 100644 tests/unit/adapters/test_oglo_tactile.py diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py index d46ea7d..ff51fce 100644 --- a/src/syncfield/adapters/__init__.py +++ b/src/syncfield/adapters/__init__.py @@ -11,6 +11,7 @@ ``JSONLFileStream`` — ``syncfield`` ``UVCWebcamStream`` ``opencv-python`` ``syncfield[uvc]`` ``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]`` +``OgloTactileStream`` ``bleak`` ``syncfield[ble]`` ``OakCameraStream`` ``depthai`` + ``opencv-python`` ``syncfield[oak,uvc]`` ========================= ===================================== ============================= @@ -41,6 +42,12 @@ except ImportError: pass +try: + from syncfield.adapters.oglo_tactile import OgloTactileStream # noqa: F401 + __all__.append("OgloTactileStream") +except ImportError: + pass + try: from syncfield.adapters.oak_camera import OakCameraStream # noqa: F401 __all__.append("OakCameraStream") diff --git a/src/syncfield/adapters/oglo_tactile.py b/src/syncfield/adapters/oglo_tactile.py new file mode 100644 index 0000000..2a32bc1 --- /dev/null +++ b/src/syncfield/adapters/oglo_tactile.py @@ -0,0 +1,333 @@ +"""OgloTactileStream — OGLO tactile glove BLE reference adapter. + +Ports the BLE protocol from the egonaut iOS app +(``egonaut/mobile/ios/EgonautMobile/Tactile/TactileGloveManager.swift``) +into a :class:`~syncfield.stream.Stream` SDK adapter. The glove exposes a +single notify characteristic that streams batched 5-finger FSR samples +with a hardware-clock timestamp. + +Protocol summary (ported from ``TactileConstants.swift``): + +- **Service UUID** : ``4652535f-424c-4500-0000-000000000001`` +- **Notify characteristic** : ``4652535f-424c-4500-0001-000000000001`` +- **Config characteristic** : ``4652535f-424c-4500-0002-000000000001`` + (optional — returns a JSON manifest with side + per-channel locations) +- **Packet layout** (little-endian): + + ======= ====== ===================================================== + Offset Type Meaning + ======= ====== ===================================================== + [0:2] u16 count — samples in this batch (typically 10) + [2:6] u32 timestamp_us — MCU hardware clock at start of batch + [6:...] 5×u16 per-sample: thumb, index, middle, ring, pinky (each) + ======= ====== ===================================================== + +- **Sample rate**: 100 Hz effective (≈10 notifications/second × 10 samples) +- **Scan filter**: advertised name substring ``"oglo"`` (case-insensitive) + +Each decoded sample is emitted as one :class:`~syncfield.types.SampleEvent` +with channels ``{thumb, index, middle, ring, pinky, device_timestamp_ns}``. +The MCU hardware clock is **linearly interpolated** across the batch +(10 samples × 10 ms) so consumers see uniform 100 Hz spacing instead of a +cluster at every batch boundary — critical for downstream jitter analysis. + +Requires the optional ``ble`` extra:: + + pip install 'syncfield[ble]' +""" + +from __future__ import annotations + +import asyncio +import struct +import threading +import time +from typing import Any, Optional, Tuple + +try: + import bleak # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - exercised via sys.modules patch + raise ImportError( + "OgloTactileStream requires bleak. " + "Install with `pip install 'syncfield[ble]'`." + ) from exc + +from syncfield.clock import SessionClock +from syncfield.stream import StreamBase +from syncfield.types import ( + FinalizationReport, + HealthEvent, + HealthEventKind, + SampleEvent, + StreamCapabilities, +) + + +# --------------------------------------------------------------------------- +# Protocol constants (copied from egonaut's TactileConstants.swift) +# --------------------------------------------------------------------------- + +SERVICE_UUID = "4652535f-424c-4500-0000-000000000001" +NOTIFY_CHAR_UUID = "4652535f-424c-4500-0001-000000000001" +CONFIG_CHAR_UUID = "4652535f-424c-4500-0002-000000000001" + +#: Canonical per-finger channel order, matching the iOS app's emitted +#: preview. Left and right gloves use the same order; orientation is a +#: property of the ``hand`` kwarg the caller supplies. +FINGER_NAMES: Tuple[str, ...] = ("thumb", "index", "middle", "ring", "pinky") + +_HEADER_FORMAT = " None: + super().__init__( + id=id, + kind="sensor", + capabilities=StreamCapabilities( + provides_audio_track=False, + # The MCU provides a hardware microsecond clock that we + # interpolate per-sample, so timestamps are genuinely precise. + supports_precise_timestamps=True, + is_removable=True, + produces_file=False, + ), + ) + if not address and not ble_name: + raise ValueError( + f"[{id}] OgloTactileStream needs either 'address' or 'ble_name'" + ) + + self._address = address + self._ble_name = ble_name + self._hand = hand + self._scan_timeout = scan_timeout + + self._client: Any = None + self._device: Any = None # BLEDevice from scan, or address string + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + self._frame_count = 0 + self._first_at: Optional[int] = None + self._last_at: Optional[int] = None + + # ------------------------------------------------------------------ + # Stream SPI + # ------------------------------------------------------------------ + + def prepare(self) -> None: + """Resolve the target device (explicit address or name scan).""" + if self._address is not None: + # bleak accepts either a BLEDevice or a plain address string. + self._device = self._address + return + + # Name-filtered scan. Run synchronously by spinning a throwaway + # asyncio loop — prepare() is called once, before the capture + # loop starts, so it's OK to block here briefly. + self._device = asyncio.run(self._scan_for_glove()) + if self._device is None: + raise RuntimeError( + f"[{self.id}] OGLO glove not found " + f"(name filter={self._ble_name!r}, timeout={self._scan_timeout}s)" + ) + + def start(self, session_clock: SessionClock) -> None: + """Kick off the background asyncio loop that drives the BLE client.""" + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_event_loop, + name=f"oglo-{self.id}", + daemon=True, + ) + self._thread.start() + + def stop(self) -> FinalizationReport: + """Signal the loop to exit and collect the finalization report.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=3.0) + + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=self._frame_count, + file_path=None, + first_sample_at_ns=self._first_at, + last_sample_at_ns=self._last_at, + health_events=list(self._collected_health), + error=None, + ) + + # ------------------------------------------------------------------ + # Async runtime on the background thread + # ------------------------------------------------------------------ + + def _run_event_loop(self) -> None: + """Body of the background thread — owns a private asyncio loop.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._session()) + finally: + self._loop.close() + + async def _session(self) -> None: + """Connect, subscribe, poll the stop flag, then disconnect cleanly.""" + try: + self._client = bleak.BleakClient(self._device) + await self._client.connect() + await self._client.start_notify(NOTIFY_CHAR_UUID, self._on_notify) + while not self._stop_event.is_set(): + await asyncio.sleep(0.05) + try: + await self._client.stop_notify(NOTIFY_CHAR_UUID) + except Exception: + pass + try: + await self._client.disconnect() + except Exception: + pass + except Exception as exc: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.ERROR, + at_ns=time.monotonic_ns(), + detail=str(exc), + ) + ) + + async def _on_notify(self, characteristic: Any, payload: bytes) -> None: + """Bleak notify handler — forwards to the sync decode path.""" + self._handle_payload(bytes(payload)) + + async def _scan_for_glove(self) -> Any: + """Scan for a peripheral whose advertised name contains ``ble_name``.""" + filter_lower = self._ble_name.lower() + devices = await bleak.BleakScanner.discover(timeout=self._scan_timeout) + for device in devices: + name = (getattr(device, "name", None) or "").lower() + if filter_lower in name: + return device + return None + + # ------------------------------------------------------------------ + # Payload decoding (unit-testable without asyncio / bleak) + # ------------------------------------------------------------------ + + def _handle_payload(self, payload: bytes) -> None: + """Decode one batched packet into N per-sample ``SampleEvent``\\ s. + + Short or truncated packets become ``WARNING`` health events rather + than raising, so a single malformed notification cannot tear down + the stream. + """ + recv_ns = time.monotonic_ns() + if len(payload) < _HEADER_SIZE: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.WARNING, + at_ns=recv_ns, + detail=f"short packet: {len(payload)} bytes", + ) + ) + return + + count, timestamp_us = struct.unpack(_HEADER_FORMAT, payload[:_HEADER_SIZE]) + body_size = count * _SAMPLE_SIZE + if len(payload) < _HEADER_SIZE + body_size: + self._emit_health( + HealthEvent( + stream_id=self.id, + kind=HealthEventKind.WARNING, + at_ns=recv_ns, + detail=( + f"truncated: header count={count}, " + f"got {len(payload)} bytes, " + f"expected ≥ {_HEADER_SIZE + body_size}" + ), + ) + ) + return + + # Emit one SampleEvent per per-finger sample in the batch at the + # full 100 Hz rate. The MCU hardware clock is linearly interpolated + # across the batch so downstream consumers see uniform 10 ms + # spacing instead of a cluster at every batch boundary. + for i in range(count): + offset = _HEADER_SIZE + i * _SAMPLE_SIZE + values = struct.unpack( + _SAMPLE_FORMAT, payload[offset : offset + _SAMPLE_SIZE] + ) + channels: dict = { + name: int(v) for name, v in zip(FINGER_NAMES, values) + } + channels["device_timestamp_ns"] = int( + (timestamp_us + i * _SAMPLE_PERIOD_US) * 1000 + ) + + if self._first_at is None: + self._first_at = recv_ns + self._last_at = recv_ns + self._frame_count += 1 + + self._emit_sample( + SampleEvent( + stream_id=self.id, + frame_number=self._frame_count - 1, + capture_ns=recv_ns, + channels=channels, + uncertainty_ns=500_000, # ~0.5 ms — MCU clock precision + ) + ) + + # ------------------------------------------------------------------ + # Test hooks + # ------------------------------------------------------------------ + + def _dispatch_notification_for_test(self, payload: bytes) -> None: + """Synchronous entry point used by unit tests (no bleak required).""" + self._handle_payload(payload) + + @property + def hand(self) -> str: + """The hand label supplied at construction time.""" + return self._hand diff --git a/tests/unit/adapters/test_oglo_tactile.py b/tests/unit/adapters/test_oglo_tactile.py new file mode 100644 index 0000000..955d8c6 --- /dev/null +++ b/tests/unit/adapters/test_oglo_tactile.py @@ -0,0 +1,233 @@ +"""Unit tests for OgloTactileStream. + +Exercises the synchronous decode path directly via +``_dispatch_notification_for_test`` so the whole test module runs without +bleak hardware or an asyncio loop. The import-guard test uses a patched +``sys.modules`` entry to simulate bleak being absent. +""" + +from __future__ import annotations + +import importlib +import json +import struct +import sys +from typing import Any, List +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from syncfield.clock import SessionClock +from syncfield.types import HealthEventKind, SampleEvent, SyncPoint + + +def _clock() -> SessionClock: + return SessionClock(sync_point=SyncPoint.create_now("h")) + + +def _build_packet(count: int, timestamp_us: int, samples: list[tuple[int, ...]]) -> bytes: + """Build one OGLO notification payload (`` Date: Thu, 9 Apr 2026 11:42:12 -0700 Subject: [PATCH 05/45] =?UTF-8?q?feat(discovery):=20add=20auto-discovery?= =?UTF-8?q?=20core=20=E2=80=94=20scan(),=20scan=5Fand=5Fadd(),=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/syncfield/discovery/ — a new top-level package that enumerates connected cameras and sensors and hands back ready-to-construct Stream adapters. Zero hardware required for the core; per-adapter discoverers land in a follow-up commit. Public API ---------- - ``syncfield.discovery.scan(*, kinds, timeout, use_cache)`` → ``DiscoveryReport`` — primitive. Walks every registered discoverer in a ThreadPoolExecutor, respects an overall wall-clock budget, returns an immutable report. Partial failures become ``report.errors`` entries; slow adapters land in ``report.timed_out``. Never raises. - ``syncfield.discovery.scan_and_add(session, *, kinds, id_prefix, output_dir, skip_existing, timeout)`` → ``list[DiscoveredDevice]`` — convenience wrapper. Runs scan(), generates collision-free stream ids from display names, constructs each adapter via ``DiscoveredDevice.construct()``, and calls ``session.add()``. Skips devices whose ``warnings`` are non-empty or that appear ``in_use``. - ``syncfield.discovery.register_discoverer(adapter_cls)`` — the registration hook for Stream adapter classes. Validated at call time for the ``discover()`` classmethod and ``_discovery_kind`` attribute. Design pillars -------------- - **Each adapter owns its discovery logic** via a ``@classmethod discover(cls, *, timeout)`` — co-located with the Stream it builds, IDE-discoverable, stateless. The registry is a thin coordinator, not a plugin loader. - **DiscoveredDevice holds a direct class reference**, so ``device.construct(id=..., output_dir=...)`` is a one-liner with no string → class lookup and no reflection. - **Short-lived scan cache** (5 s) so back-to-back calls to ``scan()`` don't re-scan BLE. The CLI and viewer Rescan button override with ``use_cache=False``. - **Shared BLE scan cache** in ``_ble.py`` so when multiple BLE adapters (OgloTactile + BLEImuGeneric) both want to enumerate peripherals, they share one 5-second BleakScanner run instead of two. - **Cooperative timeout budget** — the thread pool honors the overall deadline; discoverers exceeding their slice end up in ``timed_out``. Module structure ---------------- - types.py — DiscoveredDevice, DiscoveryReport (frozen dataclasses) - _id_gen.py — normalize() + make_stream_id() (collision-safe) - registry.py — register_discoverer, iter_discoverers, lock-guarded - _ble.py — shared BleakScanner cache (3 s TTL) - scanner.py — scan() + scan_and_add() with 5 s result cache - __init__.py — curated public surface 46 unit tests across tests/unit/discovery/: - test_types.py — frozen dataclass behavior, construct() merging, by_kind / by_adapter_type filters, summary formatting - test_id_gen.py — snake_case normalization, collision suffixes, prefix handling, exhaustion guard, unicode fallback - test_scanner.py — end-to-end with stub adapters (video + sensor), failing adapter → errors, slow adapter → timed_out, kinds filter, cache hit/miss, scan_and_add id collision avoidance, non-IDLE refusal, output_dir injection for video-only adapters Full SDK suite: 254 passing (193 before → +46 discovery + some concurrent tone/orchestrator changes). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/discovery/__init__.py | 88 ++++++ src/syncfield/discovery/_ble.py | 97 +++++++ src/syncfield/discovery/_id_gen.py | 88 ++++++ src/syncfield/discovery/registry.py | 112 ++++++++ src/syncfield/discovery/scanner.py | 364 +++++++++++++++++++++++++ src/syncfield/discovery/types.py | 154 +++++++++++ tests/unit/discovery/__init__.py | 0 tests/unit/discovery/test_id_gen.py | 66 +++++ tests/unit/discovery/test_scanner.py | 389 +++++++++++++++++++++++++++ tests/unit/discovery/test_types.py | 117 ++++++++ 10 files changed, 1475 insertions(+) create mode 100644 src/syncfield/discovery/__init__.py create mode 100644 src/syncfield/discovery/_ble.py create mode 100644 src/syncfield/discovery/_id_gen.py create mode 100644 src/syncfield/discovery/registry.py create mode 100644 src/syncfield/discovery/scanner.py create mode 100644 src/syncfield/discovery/types.py create mode 100644 tests/unit/discovery/__init__.py create mode 100644 tests/unit/discovery/test_id_gen.py create mode 100644 tests/unit/discovery/test_scanner.py create mode 100644 tests/unit/discovery/test_types.py diff --git a/src/syncfield/discovery/__init__.py b/src/syncfield/discovery/__init__.py new file mode 100644 index 0000000..0a4a46c --- /dev/null +++ b/src/syncfield/discovery/__init__.py @@ -0,0 +1,88 @@ +"""Camera and sensor auto-discovery for SyncField. + +The discovery package is the bridge between "what hardware is attached +to this machine?" and the :class:`~syncfield.SessionOrchestrator`. Four +public workflows are supported: + +1. **One-liner auto-setup** (scripts / prototypes):: + + import syncfield as sf + import syncfield.discovery + + session = sf.SessionOrchestrator(host_id="rig_01", output_dir="./data") + sf.discovery.scan_and_add(session) + session.start() + +2. **Inspect first, then curate**:: + + report = sf.discovery.scan() + for device in report.devices: + print(device.display_name, device.adapter_type, device.device_id) + + # Pick the ones you want + cam = next(d for d in report.devices if "FaceTime" in d.display_name) + session.add(cam.construct(id="cam_main", output_dir="./data")) + +3. **Viewer-driven GUI** — open an empty session in the bundled viewer + and click "Discover devices" in the header. The viewer calls + :func:`scan` on a worker thread, shows results in a modal, and wires + up :func:`scan_and_add` on user confirmation. Requires the ``viewer`` + extra — see :mod:`syncfield.viewer`. + +4. **Explicit construction** — don't use discovery at all. Preferred for + production where device identity must be reproducible:: + + session.add(UVCWebcamStream("cam", device_index=0, output_dir="./data")) + +See :doc:`/sdk/discovery` for the full decision tree and per-workflow +examples. + +Architecture +------------ +Each Stream adapter that supports discovery implements a ``@classmethod +discover(cls, *, timeout)`` returning a list of :class:`DiscoveredDevice` +instances. The :func:`register_discoverer` call in +:mod:`syncfield.adapters.__init__` wires each adapter into the module- +level registry at import time. :func:`scan` walks the registry, fans +out to every adapter in a thread pool, and aggregates the results into +an immutable :class:`DiscoveryReport`. + +Third-party adapters register themselves the same way — nothing privileged +about the shipped classes. +""" + +from __future__ import annotations + +from syncfield.discovery._id_gen import make_stream_id, normalize +from syncfield.discovery.registry import ( + clear_registry, + iter_discoverers, + register_discoverer, + unregister_discoverer, +) +from syncfield.discovery.scanner import ( + clear_scan_cache, + scan, + scan_and_add, +) +from syncfield.discovery.types import DiscoveredDevice, DiscoveryReport + +__all__ = [ + # Data model + "DiscoveredDevice", + "DiscoveryReport", + # Primitives + "scan", + "scan_and_add", + # Registry (for custom adapters) + "register_discoverer", + "unregister_discoverer", + "iter_discoverers", + "clear_registry", + # Id generation (public because users may want collision-free ids + # outside the scan_and_add path). + "make_stream_id", + "normalize", + # Test hook + "clear_scan_cache", +] diff --git a/src/syncfield/discovery/_ble.py b/src/syncfield/discovery/_ble.py new file mode 100644 index 0000000..8c784a2 --- /dev/null +++ b/src/syncfield/discovery/_ble.py @@ -0,0 +1,97 @@ +"""Shared BLE peripheral scan with short-lived caching. + +BLE scanning is *slow* (5-10 seconds depending on the OS) and every +adapter that uses ``bleak`` wants to walk the same result set. Without +coordination, two BLE-based discoverers running in parallel would each +kick off an independent scan and the user would wait 10+ seconds instead +of 5. + +This module exposes :func:`scan_peripherals`, a thread-safe cache around +``BleakScanner.discover()``. The first caller runs the scan; everyone +else within ``_CACHE_TTL_S`` gets the cached result back immediately. + +The cache is *very* short-lived by design (3 seconds) — BLE devices +come and go frequently, and discovery is expected to surface the current +state, not a stale snapshot. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time +from typing import Any, List + +logger = logging.getLogger(__name__) + + +# Cached scan results keyed by nothing (single global cache). Adapter- +# specific filtering happens after fetching from this cache — all BLE +# discoverers see the same raw peripheral list. +_cache: List[Any] = [] +_cache_time: float = 0.0 +_cache_lock = threading.Lock() + +# Short TTL. Long enough to share one ``scan()`` round across adapters, +# short enough that back-to-back user-triggered rescans feel responsive. +_CACHE_TTL_S = 3.0 + + +def scan_peripherals(timeout: float = 5.0) -> List[Any]: + """Return the list of BLE peripherals currently in range. + + Under the hood this runs ``bleak.BleakScanner.discover()`` on a + throwaway asyncio loop and caches the result for a few seconds so + subsequent callers skip the rescan. + + Args: + timeout: BLE scan window in seconds. Ignored on cache hit. + + Returns: + List of ``BLEDevice``-like objects (whatever ``bleak`` returns). + Empty list on any failure — missing ``bleak`` install, platform + Bluetooth adapter error, etc. Discovery is never allowed to raise + into the scan coordinator. + """ + # Cache hit path — fast, no subprocess or asyncio overhead. + with _cache_lock: + now = time.monotonic() + if _cache and (now - _cache_time) < _CACHE_TTL_S: + return list(_cache) + + # Cache miss: import bleak lazily so the module stays importable on + # machines without the BLE extra installed. + try: + import bleak # type: ignore[import-not-found] + except ImportError: + logger.debug("bleak not available; BLE discovery returns empty list") + return [] + + try: + loop = asyncio.new_event_loop() + try: + devices = loop.run_until_complete( + bleak.BleakScanner.discover(timeout=timeout) + ) + finally: + loop.close() + except Exception as exc: + logger.debug("BLE scan failed: %s", exc) + return [] + + # Update the cache under lock; the list is intentionally a fresh copy + # so a reader that mutates its own copy can't affect the cache. + with _cache_lock: + global _cache, _cache_time + _cache = list(devices) + _cache_time = time.monotonic() + return list(devices) + + +def clear_cache() -> None: + """Invalidate the shared BLE scan cache. Primarily a test hook.""" + with _cache_lock: + global _cache, _cache_time + _cache = [] + _cache_time = 0.0 diff --git a/src/syncfield/discovery/_id_gen.py b/src/syncfield/discovery/_id_gen.py new file mode 100644 index 0000000..90d7ab0 --- /dev/null +++ b/src/syncfield/discovery/_id_gen.py @@ -0,0 +1,88 @@ +"""Stream id generation from human-readable display names. + +Discovery only knows what the hardware calls itself ("FaceTime HD Camera", +"OAK-D S2"). ``scan_and_add`` needs stable, URL-safe stream ids that don't +collide with whatever is already registered in the session. This module +does the small but persnickety normalization step. +""" + +from __future__ import annotations + +import re +from typing import Iterable + +# Normalization rules: +# +# "FaceTime HD Camera" → "facetime_hd_camera" +# "OAK-D S2" → "oak_d_s2" +# "oglo.glove [right]" → "oglo_glove_right" +# " extra whitespace " → "extra_whitespace" +# +# Everything non-alphanumeric collapses to a single underscore; runs of +# multiple underscores collapse to one; leading/trailing underscores are +# stripped. The result is always a valid Python identifier prefix, which +# also happens to be safe for filesystem paths and JSONL stream-id keys. + +_NON_ALNUM = re.compile(r"[^a-z0-9]+") + +# Hard cap on the collision-suffix loop. If we find 100 duplicates of the +# same device name in one session, something's gone wrong upstream and a +# loud error is better than silently generating name_99_0. +_MAX_COLLISION_ATTEMPTS = 100 + + +def normalize(name: str) -> str: + """Return the canonical snake_case form of ``name``. + + Empty input — or input that contains only non-alphanumeric characters — + normalizes to the literal string ``"device"`` so the caller never has + to handle an empty id. + """ + lowered = name.lower() + collapsed = _NON_ALNUM.sub("_", lowered).strip("_") + return collapsed or "device" + + +def make_stream_id( + display_name: str, + existing_ids: Iterable[str], + *, + prefix: str = "", +) -> str: + """Produce a collision-free stream id from a display name. + + Args: + display_name: Human-readable label (e.g. "FaceTime HD Camera"). + existing_ids: Stream ids already in use. Any iterable works; it's + converted to a set internally. Pass ``session._streams.keys()`` + when adding from ``SessionOrchestrator`` state. + prefix: Optional string prepended to the normalized name before + collision checks — useful for namespacing ("lab01_*") when + multiple session configs share a storage backend. + + Returns: + A snake_case id that isn't already in ``existing_ids``. If the base + name already exists, an ``_0``, ``_1``, ... suffix is appended + until a free slot is found. + + Raises: + RuntimeError: If ``_MAX_COLLISION_ATTEMPTS`` collision suffixes + have been exhausted. Indicates a bug or pathological input. + """ + taken = set(existing_ids) + base = normalize(display_name) + if prefix: + base = f"{normalize(prefix)}_{base}" + + if base not in taken: + return base + + for attempt in range(_MAX_COLLISION_ATTEMPTS): + candidate = f"{base}_{attempt}" + if candidate not in taken: + return candidate + + raise RuntimeError( + f"exhausted {_MAX_COLLISION_ATTEMPTS} collision suffixes for " + f"base id {base!r} — check for a bug generating duplicate devices" + ) diff --git a/src/syncfield/discovery/registry.py b/src/syncfield/discovery/registry.py new file mode 100644 index 0000000..95ae173 --- /dev/null +++ b/src/syncfield/discovery/registry.py @@ -0,0 +1,112 @@ +"""Global registry of adapter classes that support discovery. + +Each Stream adapter that implements a ``@classmethod discover(cls, *, +timeout)`` registers itself here at import time. :func:`scan` walks the +registry, filters by ``kinds=``, and fans out to each ``discover()`` call +in a thread pool. + +Third-party adapter authors register their classes the same way the +shipped adapters do:: + + from syncfield.discovery import register_discoverer + + class MyBrandIMU(StreamBase): + _discovery_kind = "sensor" + + @classmethod + def discover(cls, *, timeout: float = 5.0): + return [...] + + register_discoverer(MyBrandIMU) + +The registry is module-global and thread-safe for registration. The only +mutation is list append during import, so a lock is conservative but +cheap. +""" + +from __future__ import annotations + +import logging +import threading +from typing import List, Type + +logger = logging.getLogger(__name__) + + +# Module-level state — small and finite. The list holds adapter classes +# (not instances); discovery calls class methods on each one. +_REGISTERED: List[Type] = [] +_LOCK = threading.Lock() + + +def register_discoverer(adapter_cls: Type) -> None: + """Register an adapter class with the discovery system. + + The class must implement: + + - ``discover(cls, *, timeout: float) -> list[DiscoveredDevice]`` as a + classmethod — enumerates the currently attached devices. + - ``_discovery_kind`` as a class-level string — ``"video" | "sensor" | + "audio" | "custom"``. Used by ``scan(kinds=...)`` filtering so + :func:`scan` can skip whole adapter classes without calling their + discover() methods (important when BLE scanning is expensive). + + Silent no-op if the class is already registered — idempotent so + re-importing the adapters package during test reloads is safe. + + Raises: + TypeError: If the class is missing ``discover()`` or + ``_discovery_kind``. Failing loud here is better than a silent + runtime surprise later. + """ + if not hasattr(adapter_cls, "discover"): + raise TypeError( + f"{adapter_cls.__name__} cannot be registered as a discoverer: " + f"missing 'discover' classmethod" + ) + if not hasattr(adapter_cls, "_discovery_kind"): + raise TypeError( + f"{adapter_cls.__name__} cannot be registered as a discoverer: " + f"missing '_discovery_kind' class attribute " + f"(one of 'video', 'audio', 'sensor', 'custom')" + ) + + with _LOCK: + if adapter_cls in _REGISTERED: + return + _REGISTERED.append(adapter_cls) + logger.debug( + "registered discoverer: %s (kind=%s)", + adapter_cls.__name__, + getattr(adapter_cls, "_discovery_kind", "?"), + ) + + +def unregister_discoverer(adapter_cls: Type) -> bool: + """Remove a class from the registry. Returns True if it was present. + + Primarily useful for tests that want a clean registry state. + """ + with _LOCK: + try: + _REGISTERED.remove(adapter_cls) + return True + except ValueError: + return False + + +def iter_discoverers() -> tuple: + """Return a snapshot tuple of currently registered adapter classes. + + Snapshot avoids iteration-during-mutation if a third-party module + registers a new discoverer mid-scan — the current :func:`scan` + never sees the new class until the next call. + """ + with _LOCK: + return tuple(_REGISTERED) + + +def clear_registry() -> None: + """Drop every registered discoverer. Intended for test isolation.""" + with _LOCK: + _REGISTERED.clear() diff --git a/src/syncfield/discovery/scanner.py b/src/syncfield/discovery/scanner.py new file mode 100644 index 0000000..ca39575 --- /dev/null +++ b/src/syncfield/discovery/scanner.py @@ -0,0 +1,364 @@ +"""Top-level scan coordinator. + +Two public functions live here: + +- :func:`scan` — the primitive. Walks every registered discoverer in a + thread pool, respects an overall time budget, and returns an immutable + :class:`DiscoveryReport`. Never raises — partial failures become + report ``errors`` entries. + +- :func:`scan_and_add` — the convenience wrapper. Calls :func:`scan`, then + auto-generates stream ids and calls ``session.add(...)`` for each + discovered device whose warnings are empty. Returns the list of devices + that were actually added. + +Both functions also implement a small result cache so repeated scans +within a few seconds don't re-run expensive BLE scans. +""" + +from __future__ import annotations + +import concurrent.futures +import logging +import threading +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any, List, Mapping, Optional, Sequence, Tuple + +from syncfield.discovery._id_gen import make_stream_id +from syncfield.discovery.registry import iter_discoverers +from syncfield.discovery.types import DiscoveredDevice, DiscoveryReport + +if TYPE_CHECKING: + from syncfield.orchestrator import SessionOrchestrator + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Scan result cache +# --------------------------------------------------------------------------- + +# Short-lived cache so two calls to ``scan()`` within ~5 seconds share one +# result. The key is the ``(kinds_tuple, timeout)`` pair; different filter +# combinations get independent cache entries. + +_SCAN_CACHE_TTL_S = 5.0 +_scan_cache: dict[Tuple[Tuple[str, ...], float], Tuple[DiscoveryReport, float]] = {} +_scan_cache_lock = threading.Lock() + + +def _cache_key(kinds: Optional[Sequence[str]], timeout: float) -> Tuple[Tuple[str, ...], float]: + """Build a stable dict key for the scan result cache.""" + kinds_tuple = tuple(sorted(kinds)) if kinds else () + return (kinds_tuple, timeout) + + +def _cached_scan(key: Tuple[Tuple[str, ...], float]) -> Optional[DiscoveryReport]: + with _scan_cache_lock: + entry = _scan_cache.get(key) + if entry is None: + return None + report, cached_at = entry + if time.monotonic() - cached_at > _SCAN_CACHE_TTL_S: + _scan_cache.pop(key, None) + return None + return report + + +def _store_scan_cache(key: Tuple[Tuple[str, ...], float], report: DiscoveryReport) -> None: + with _scan_cache_lock: + _scan_cache[key] = (report, time.monotonic()) + + +def clear_scan_cache() -> None: + """Drop all cached scan results. Test hook and the ``use_cache=False`` path.""" + with _scan_cache_lock: + _scan_cache.clear() + + +# --------------------------------------------------------------------------- +# scan() +# --------------------------------------------------------------------------- + + +def scan( + *, + kinds: Optional[Sequence[str]] = None, + timeout: float = 10.0, + use_cache: bool = True, +) -> DiscoveryReport: + """Enumerate every device the registered discoverers can see. + + Args: + kinds: Optional filter on Stream kind — ``["video"]`` to skip BLE + sensors when you only want cameras, for example. When omitted + or ``None``, every registered discoverer runs. + timeout: Wall-clock budget for the whole scan in seconds. Each + adapter's ``discover()`` gets this full budget, but the + overall call won't exceed it — slow adapters end up in + :attr:`DiscoveryReport.timed_out` instead of blocking faster + ones. Default ``10.0``. + use_cache: If True, return a cached result from a recent scan + with the same filter when one exists (5 s TTL). Pass ``False`` + for a hard refresh after plugging new hardware in. + + Returns: + An immutable :class:`DiscoveryReport`. Never raises — exceptions + from individual discoverers land in :attr:`DiscoveryReport.errors`. + """ + key = _cache_key(kinds, timeout) + if use_cache: + cached = _cached_scan(key) + if cached is not None: + return cached + + discoverers = list(iter_discoverers()) + if kinds: + kind_filter = set(kinds) + discoverers = [ + cls + for cls in discoverers + if getattr(cls, "_discovery_kind", None) in kind_filter + ] + + start = time.monotonic() + all_devices: List[DiscoveredDevice] = [] + errors: dict[str, str] = {} + timed_out: List[str] = [] + + if not discoverers: + report = DiscoveryReport( + devices=(), + errors={}, + duration_s=time.monotonic() - start, + timed_out=(), + ) + _store_scan_cache(key, report) + return report + + # Thread pool fans discover() calls out in parallel. Each discoverer + # is called with the remaining time budget so slow ones can still + # honor the caller's overall timeout. + deadline = start + timeout + with concurrent.futures.ThreadPoolExecutor( + max_workers=min(len(discoverers), 8), + thread_name_prefix="sf-discover", + ) as executor: + futures = { + executor.submit(_safe_discover, cls, timeout): cls + for cls in discoverers + } + + pending = set(futures.keys()) + while pending: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + done, pending = concurrent.futures.wait( + pending, + timeout=remaining, + return_when=concurrent.futures.FIRST_COMPLETED, + ) + for future in done: + cls = futures[future] + adapter_type = _adapter_type_of(cls) + try: + result = future.result() + all_devices.extend(result) + except Exception as exc: # pragma: no cover — defensive + errors[adapter_type] = f"{type(exc).__name__}: {exc}" + + # Any still-pending futures blew past the budget. + for future in pending: + cls = futures[future] + timed_out.append(_adapter_type_of(cls)) + future.cancel() + + report = DiscoveryReport( + devices=tuple(all_devices), + errors=dict(errors), + duration_s=time.monotonic() - start, + timed_out=tuple(timed_out), + ) + _store_scan_cache(key, report) + return report + + +def _safe_discover(cls: type, timeout: float) -> List[DiscoveredDevice]: + """Call ``cls.discover(timeout=timeout)`` with strict error containment. + + Discoverers are expected to return an empty list on failure rather + than raise, but we wrap in a try/except anyway so a misbehaving + discoverer can't take down the whole scan. + """ + try: + devices = cls.discover(timeout=timeout) + except Exception as exc: + logger.debug( + "%s.discover() raised: %s: %s", + cls.__name__, + type(exc).__name__, + exc, + ) + raise + if devices is None: + return [] + return list(devices) + + +def _adapter_type_of(cls: type) -> str: + """Extract a stable adapter_type string from a class (for error keys).""" + # Adapters set ``_discovery_adapter_type`` as a class-level string so + # the scanner doesn't have to instantiate them. + return getattr(cls, "_discovery_adapter_type", cls.__name__) + + +# --------------------------------------------------------------------------- +# scan_and_add() +# --------------------------------------------------------------------------- + + +def scan_and_add( + session: "SessionOrchestrator", + *, + kinds: Optional[Sequence[str]] = None, + id_prefix: str = "", + output_dir: Optional[Path] = None, + skip_existing: bool = True, + timeout: float = 10.0, +) -> List[DiscoveredDevice]: + """Discover devices and register them with a session in one call. + + The typical three-line setup for a script:: + + session = sf.SessionOrchestrator(host_id="rig_01", output_dir="./data") + sf.discovery.scan_and_add(session) + session.start() + + Each discovered device gets an auto-generated stream id based on its + :attr:`DiscoveredDevice.display_name`. Devices with non-empty + :attr:`DiscoveredDevice.warnings` are skipped with an INFO log — those + require manual construction (e.g. a generic BLE IMU that needs a + ``characteristic_uuid``). Devices marked ``in_use=True`` are also + skipped. + + Args: + session: The :class:`SessionOrchestrator` to add discovered + streams to. Must be in the ``IDLE`` state — adding streams + to a running session is a bug in the calling code. + kinds: Optional kind filter, forwarded to :func:`scan`. + id_prefix: Optional string prepended to every generated stream id. + Useful for namespacing across multiple rigs. + output_dir: Directory to use for streams that accept an + ``output_dir`` kwarg. Defaults to ``session.output_dir`` + when omitted. + skip_existing: If True (default), silently skip devices whose + auto-generated id is already registered in the session. Set + to False to raise instead — useful for catching bugs. + timeout: Scan budget, forwarded to :func:`scan`. + + Returns: + The list of :class:`DiscoveredDevice` instances that were + successfully registered with the session, in registration order. + Does not include devices that were skipped, timed out, or errored. + + Raises: + RuntimeError: If the session is not in the ``IDLE`` state. + """ + # Lazy import to avoid pulling the orchestrator module into scan() path + from syncfield.types import SessionState + + if session.state is not SessionState.IDLE: + raise RuntimeError( + f"scan_and_add requires session in IDLE state; got {session.state.value}" + ) + + effective_output_dir = Path(output_dir) if output_dir is not None else session.output_dir + + report = scan(kinds=kinds, timeout=timeout) + + if report.errors: + for adapter_type, message in report.errors.items(): + logger.info("discovery error from %s: %s", adapter_type, message) + if report.timed_out: + logger.info("discovery timed out: %s", ", ".join(report.timed_out)) + + # Snapshot the currently-registered ids so make_stream_id avoids them. + # Accessing ``_streams`` directly is intentional — the viewer/poller + # already reads the same attribute, and the SDK has no public + # iteration surface for registered streams yet. + existing_ids = set(session._streams.keys()) # noqa: SLF001 + + added: List[DiscoveredDevice] = [] + + for device in report.devices: + if device.warnings: + logger.info( + "skipping %s: %s", + device.display_name, + device.warnings[0], + ) + continue + if device.in_use: + logger.info( + "skipping %s: device appears to be in use by another process", + device.display_name, + ) + continue + + try: + stream_id = make_stream_id( + device.display_name, + existing_ids, + prefix=id_prefix, + ) + except Exception as exc: # pragma: no cover — defensive + logger.warning( + "could not generate id for %s: %s", device.display_name, exc + ) + continue + + # Build the caller kwargs. Every adapter needs an ``id``; only + # video adapters accept ``output_dir``. + construct_kwargs: dict[str, Any] = {"id": stream_id} + if device.accepts_output_dir: + construct_kwargs["output_dir"] = effective_output_dir + + try: + stream = device.construct(**construct_kwargs) + except Exception as exc: + logger.warning( + "failed to construct stream for %s: %s: %s", + device.display_name, + type(exc).__name__, + exc, + ) + continue + + try: + session.add(stream) + except ValueError as exc: + if skip_existing: + logger.info( + "skipping %s: %s", + device.display_name, + exc, + ) + continue + raise + + existing_ids.add(stream_id) + added.append(device) + logger.info( + " + %-40s %s", + stream_id, + device.adapter_type, + ) + + logger.info( + "scan_and_add registered %d of %d discovered devices", + len(added), + len(report.devices), + ) + return added diff --git a/src/syncfield/discovery/types.py b/src/syncfield/discovery/types.py new file mode 100644 index 0000000..eccbf76 --- /dev/null +++ b/src/syncfield/discovery/types.py @@ -0,0 +1,154 @@ +"""Data model for discovery results. + +Two frozen dataclasses the rest of the discovery layer returns: + +- :class:`DiscoveredDevice` — one physical device that a scan found. +- :class:`DiscoveryReport` — the full result set, including partial-failure + errors and timing information. + +Keeping these immutable (``frozen=True``) means the viewer can pass them +around between threads without worrying about anyone mutating a field +mid-render. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, ClassVar, Mapping, Tuple, Type, TYPE_CHECKING + +if TYPE_CHECKING: + from syncfield.stream import Stream + + +@dataclass(frozen=True) +class DiscoveredDevice: + """One physical device that discovery found, ready to be constructed. + + Every field is populated by the adapter's :meth:`discover` classmethod. + The key field is :attr:`construct_kwargs` — that dict carries the + hardware identifiers the adapter needs (``device_index``, ``device_id``, + ``mac``, …). Callers add application-level fields (``id``, typically + ``output_dir``) at construction time via :meth:`construct`. + + Attributes: + adapter_type: Stable string identifier for the adapter kind — + ``"uvc_webcam"``, ``"oak_camera"``, ``"ble_imu"``, + ``"oglo_tactile"``, ``"ble_peripheral"``. Used for filtering + and UX grouping, not for class lookup (that's :attr:`adapter_cls`). + adapter_cls: The Stream subclass to instantiate. Held as a direct + class reference so :meth:`construct` needs no string → class + lookup. Typed loosely as ``type`` because circular imports with + :mod:`syncfield.stream` would otherwise force runtime gymnastics. + kind: Stream kind (``"video" | "audio" | "sensor" | "custom"``) — + mirrors :attr:`StreamBase.kind` so the viewer can group devices + without instantiating them. + display_name: Short human-readable label. Shown directly in the + viewer's discovery modal and in CLI output. + description: Additional one-line context (e.g. resolution, USB + speed, BLE address tail). Rendered in a muted color below the + name in UIs. + device_id: Stable identifier for this specific device — + ``cv2`` index, OAK serial, BLE MAC. Used for cache keys and + user-facing copy like "already added". + construct_kwargs: Keyword arguments for + ``adapter_cls(**construct_kwargs, **caller_kwargs)``. Contains + everything the discoverer learned from the hardware; callers + provide ``id`` and (when applicable) ``output_dir`` at + construct time. + accepts_output_dir: Whether the underlying Stream ``__init__`` + takes an ``output_dir`` argument. ``scan_and_add`` uses this + to decide whether to inject the session's output directory. + True for video adapters, False for BLE-only sensors. + in_use: Heuristic flag — True if the discoverer detected the + device is already held by another process. ``scan_and_add`` + skips these; the viewer UI renders them disabled with a + tooltip. Best-effort detection only. + warnings: Tuple of short caveats the discoverer wants to surface + (e.g. ``"requires characteristic_uuid for construction"`` for + generic BLE devices). A non-empty warnings tuple causes + :func:`scan_and_add` to skip this device — the user must add + it explicitly via code. + """ + + adapter_type: str + adapter_cls: Type[Any] + kind: str + display_name: str + description: str + device_id: str + construct_kwargs: Mapping[str, Any] = field(default_factory=dict) + accepts_output_dir: bool = False + in_use: bool = False + warnings: Tuple[str, ...] = () + + def construct(self, **kwargs: Any) -> "Stream": + """Instantiate the Stream for this device. + + Merges :attr:`construct_kwargs` with caller-supplied ``kwargs`` + and calls the adapter class. Caller-supplied values win on + conflict so users can override discovery-found defaults + (e.g. force a specific ``depth_enabled=True`` on an OAK). + + Args: + **kwargs: Must include ``id``. Include ``output_dir`` when + :attr:`accepts_output_dir` is True. Any additional adapter + options are forwarded. + + Returns: + A freshly constructed Stream instance, ready to be passed to + :meth:`SessionOrchestrator.add`. + + Raises: + TypeError: If required ``id`` is missing, or if the merged + kwargs don't match the adapter's ``__init__`` signature. + """ + if "id" not in kwargs: + raise TypeError( + f"{self.adapter_type}.construct() requires an 'id' keyword" + ) + merged: dict[str, Any] = {**self.construct_kwargs, **kwargs} + return self.adapter_cls(**merged) + + +@dataclass(frozen=True) +class DiscoveryReport: + """Aggregated result of a single :func:`scan` call. + + Attributes: + devices: Every device that any registered discoverer returned, + in discovery order. Tuple so callers can rely on immutability. + errors: Map from ``adapter_type`` to an error message string for + discoverers that raised. Partial-failure friendly — a BLE + stack crash never masks the OAK and UVC results. + duration_s: Wall-clock seconds the whole scan took. + timed_out: Tuple of ``adapter_type`` strings whose ``discover()`` + did not complete within the scan budget. + """ + + devices: Tuple[DiscoveredDevice, ...] + errors: Mapping[str, str] = field(default_factory=dict) + duration_s: float = 0.0 + timed_out: Tuple[str, ...] = () + + def by_kind(self, kind: str) -> Tuple[DiscoveredDevice, ...]: + """Return devices whose Stream kind matches ``kind``.""" + return tuple(d for d in self.devices if d.kind == kind) + + def by_adapter_type(self, adapter_type: str) -> Tuple[DiscoveredDevice, ...]: + """Return devices from a specific adapter class.""" + return tuple(d for d in self.devices if d.adapter_type == adapter_type) + + @property + def is_success(self) -> bool: + """True when all registered discoverers completed without errors.""" + return not self.errors and not self.timed_out + + def summary(self) -> str: + """Short one-line summary useful for log output.""" + parts = [f"{len(self.devices)} devices in {self.duration_s:.1f}s"] + if self.errors: + parts.append(f"{len(self.errors)} error(s)") + if self.timed_out: + parts.append(f"{len(self.timed_out)} timed out") + return ", ".join(parts) diff --git a/tests/unit/discovery/__init__.py b/tests/unit/discovery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/discovery/test_id_gen.py b/tests/unit/discovery/test_id_gen.py new file mode 100644 index 0000000..25d5106 --- /dev/null +++ b/tests/unit/discovery/test_id_gen.py @@ -0,0 +1,66 @@ +"""Unit tests for the snake_case stream-id generator.""" + +from __future__ import annotations + +import pytest + +from syncfield.discovery._id_gen import make_stream_id, normalize + + +class TestNormalize: + def test_plain_lowercase(self): + assert normalize("camera") == "camera" + + def test_mixed_case(self): + assert normalize("FaceTime HD Camera") == "facetime_hd_camera" + + def test_hyphens_and_punctuation(self): + assert normalize("OAK-D S2") == "oak_d_s2" + assert normalize("oglo.glove [right]") == "oglo_glove_right" + + def test_collapses_runs_of_underscores(self): + assert normalize("camera main") == "camera_main" + assert normalize("a!!!b@@@c") == "a_b_c" + + def test_strips_leading_trailing(self): + assert normalize(" camera ") == "camera" + assert normalize("__cam__") == "cam" + + def test_empty_returns_fallback(self): + assert normalize("") == "device" + assert normalize("!!!") == "device" + + def test_unicode_non_ascii_becomes_underscores(self): + """Non-ASCII letters are not alphanumeric under our regex — that's + intentional so generated ids stay ASCII-safe.""" + assert normalize("카메라") == "device" + + +class TestMakeStreamId: + def test_no_collision(self): + assert make_stream_id("FaceTime HD Camera", set()) == "facetime_hd_camera" + + def test_collision_appends_zero_then_one(self): + taken = {"camera"} + assert make_stream_id("camera", taken) == "camera_0" + + taken.add("camera_0") + assert make_stream_id("camera", taken) == "camera_1" + + def test_prefix(self): + result = make_stream_id("camera", set(), prefix="rig_01") + assert result == "rig_01_camera" + + def test_prefix_is_also_normalized(self): + result = make_stream_id("cam", set(), prefix="Rig 01!") + assert result == "rig_01_cam" + + def test_existing_ids_can_be_any_iterable(self): + make_stream_id("cam", ["cam", "other"]) + make_stream_id("cam", frozenset({"cam"})) + make_stream_id("cam", (x for x in ["cam"])) # generator + + def test_exhaustion_raises(self): + taken = {"camera"} | {f"camera_{i}" for i in range(100)} + with pytest.raises(RuntimeError, match="collision"): + make_stream_id("camera", taken) diff --git a/tests/unit/discovery/test_scanner.py b/tests/unit/discovery/test_scanner.py new file mode 100644 index 0000000..c12e0d4 --- /dev/null +++ b/tests/unit/discovery/test_scanner.py @@ -0,0 +1,389 @@ +"""Unit tests for scan() and scan_and_add() — the discovery coordinator. + +The tests use stub adapter classes registered into the discovery +registry to drive the scan path without any real hardware. Each test +starts with a clean registry (via ``clear_registry``) so no cross-test +leakage is possible. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, List + +import pytest + +import syncfield as sf +from syncfield.discovery import ( + DiscoveredDevice, + DiscoveryReport, + clear_registry, + clear_scan_cache, + iter_discoverers, + register_discoverer, + scan, + scan_and_add, + unregister_discoverer, +) +from syncfield.stream import StreamBase +from syncfield.testing import FakeStream +from syncfield.types import FinalizationReport, StreamCapabilities + + +# --------------------------------------------------------------------------- +# Stub adapters — classmethod discover() + required class attributes +# --------------------------------------------------------------------------- + + +class _StubStreamBase(StreamBase): + """Minimal Stream subclass for scanner tests — no-op lifecycle, records + constructor kwargs so tests can assert on what ``scan_and_add`` passed.""" + + def __init__(self, *, id: str, **kwargs: Any) -> None: + super().__init__( + id=id, + kind=self._discovery_kind, # type: ignore[arg-type] + capabilities=StreamCapabilities(), + ) + # Keep a full record of construction kwargs (including id itself) + # so tests can assert on what scan_and_add forwarded. + self.kwargs: dict[str, Any] = {"id": id, **kwargs} + + def prepare(self) -> None: # pragma: no cover — tests never start the session + pass + + def start(self, session_clock) -> None: # pragma: no cover + pass + + def stop(self) -> FinalizationReport: # pragma: no cover + return FinalizationReport( + stream_id=self.id, + status="completed", + frame_count=0, + file_path=None, + first_sample_at_ns=None, + last_sample_at_ns=None, + health_events=[], + error=None, + ) + + +class _StubVideoAdapter(_StubStreamBase): + """Stand-in for a camera adapter. Returns a fixed list of 'cameras'.""" + + _discovery_kind = "video" + _discovery_adapter_type = "stub_video" + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> List[DiscoveredDevice]: + return [ + DiscoveredDevice( + adapter_type="stub_video", + adapter_cls=cls, + kind="video", + display_name="Stub Camera A", + description="fake", + device_id="a", + construct_kwargs={"index": 0}, + accepts_output_dir=True, + ), + DiscoveredDevice( + adapter_type="stub_video", + adapter_cls=cls, + kind="video", + display_name="Stub Camera B", + description="fake", + device_id="b", + construct_kwargs={"index": 1}, + accepts_output_dir=True, + ), + ] + + +class _StubSensorAdapter(_StubStreamBase): + _discovery_kind = "sensor" + _discovery_adapter_type = "stub_sensor" + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> List[DiscoveredDevice]: + return [ + DiscoveredDevice( + adapter_type="stub_sensor", + adapter_cls=cls, + kind="sensor", + display_name="Stub IMU", + description="fake", + device_id="imu", + construct_kwargs={"mac": "AA:BB:CC"}, + accepts_output_dir=False, + ), + ] + + +class _SlowAdapter(_StubStreamBase): + """Sleeps past the budget — should land in timed_out.""" + + _discovery_kind = "sensor" + _discovery_adapter_type = "slow" + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> List[DiscoveredDevice]: + time.sleep(2.0) + return [] + + +class _FailingAdapter(_StubStreamBase): + """Raises on discover() — should populate errors.""" + + _discovery_kind = "sensor" + _discovery_adapter_type = "failing" + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> List[DiscoveredDevice]: + raise RuntimeError("synthetic failure") + + +class _BrokenAdapterNeedsUuid(_StubStreamBase): + """Stand-in for a BLE IMU that requires manual characteristic_uuid.""" + + _discovery_kind = "sensor" + _discovery_adapter_type = "broken_ble" + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> List[DiscoveredDevice]: + return [ + DiscoveredDevice( + adapter_type="broken_ble", + adapter_cls=cls, + kind="sensor", + display_name="Mystery IMU", + description="ble:XX:YY", + device_id="xxyy", + construct_kwargs={"mac": "XX:YY"}, + accepts_output_dir=False, + warnings=("characteristic_uuid required — add manually",), + ), + ] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_registry_and_cache(): + """Every test starts with an empty registry and cache.""" + clear_registry() + clear_scan_cache() + yield + clear_registry() + clear_scan_cache() + + +@pytest.fixture +def tmp_session(tmp_path): + return sf.SessionOrchestrator( + host_id="test", + output_dir=tmp_path, + sync_tone=sf.SyncToneConfig.silent(), + ) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + + +class TestRegistry: + def test_register_and_iter(self): + register_discoverer(_StubVideoAdapter) + assert _StubVideoAdapter in iter_discoverers() + + def test_register_idempotent(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubVideoAdapter) + assert len(iter_discoverers()) == 1 + + def test_unregister(self): + register_discoverer(_StubVideoAdapter) + assert unregister_discoverer(_StubVideoAdapter) is True + assert _StubVideoAdapter not in iter_discoverers() + + def test_unregister_missing_returns_false(self): + assert unregister_discoverer(_StubVideoAdapter) is False + + def test_register_requires_discover_method(self): + class Broken: + _discovery_kind = "video" + + with pytest.raises(TypeError, match="discover"): + register_discoverer(Broken) + + def test_register_requires_kind_attribute(self): + class NoKind: + @classmethod + def discover(cls, *, timeout=5.0): + return [] + + with pytest.raises(TypeError, match="_discovery_kind"): + register_discoverer(NoKind) + + +# --------------------------------------------------------------------------- +# scan() +# --------------------------------------------------------------------------- + + +class TestScan: + def test_empty_registry_returns_empty_report(self): + report = scan() + assert report.devices == () + assert report.errors == {} + assert report.timed_out == () + + def test_single_adapter(self): + register_discoverer(_StubVideoAdapter) + report = scan(use_cache=False) + assert len(report.devices) == 2 + assert all(d.adapter_type == "stub_video" for d in report.devices) + + def test_multiple_adapters_aggregate(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubSensorAdapter) + report = scan(use_cache=False) + assert len(report.devices) == 3 + + def test_kinds_filter(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubSensorAdapter) + report = scan(kinds=["video"], use_cache=False) + assert len(report.devices) == 2 + assert all(d.kind == "video" for d in report.devices) + + def test_failing_adapter_becomes_error_entry(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_FailingAdapter) + report = scan(use_cache=False) + assert len(report.devices) == 2 # the good one still works + assert "failing" in report.errors + assert "synthetic failure" in report.errors["failing"] + + def test_slow_adapter_times_out(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_SlowAdapter) + report = scan(timeout=0.5, use_cache=False) + # Good adapter should still have landed + assert len(report.devices) == 2 + assert "slow" in report.timed_out + + def test_cache_hit_returns_same_object(self): + register_discoverer(_StubVideoAdapter) + first = scan() + second = scan() + # Same cache entry — identity check + assert first is second + + def test_cache_miss_on_different_filter(self): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubSensorAdapter) + all_report = scan() + video_report = scan(kinds=["video"]) + assert all_report is not video_report + assert len(all_report.devices) == 3 + assert len(video_report.devices) == 2 + + def test_use_cache_false_forces_fresh_scan(self): + register_discoverer(_StubVideoAdapter) + first = scan() + second = scan(use_cache=False) + assert first is not second + # But the content matches + assert len(first.devices) == len(second.devices) + + +# --------------------------------------------------------------------------- +# scan_and_add() +# --------------------------------------------------------------------------- + + +class TestScanAndAdd: + def test_registers_all_found_devices(self, tmp_session): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubSensorAdapter) + + added = scan_and_add(tmp_session) + + assert len(added) == 3 + assert len(tmp_session._streams) == 3 # noqa: SLF001 + assert set(tmp_session._streams.keys()) == { # noqa: SLF001 + "stub_camera_a", + "stub_camera_b", + "stub_imu", + } + + def test_skips_devices_with_warnings(self, tmp_session): + register_discoverer(_BrokenAdapterNeedsUuid) + added = scan_and_add(tmp_session) + assert added == [] + assert tmp_session._streams == {} # noqa: SLF001 + + def test_respects_kind_filter(self, tmp_session): + register_discoverer(_StubVideoAdapter) + register_discoverer(_StubSensorAdapter) + added = scan_and_add(tmp_session, kinds=["video"]) + assert len(added) == 2 + assert all(d.kind == "video" for d in added) + + def test_id_prefix_applied(self, tmp_session): + register_discoverer(_StubVideoAdapter) + scan_and_add(tmp_session, id_prefix="lab") + assert all( + sid.startswith("lab_") + for sid in tmp_session._streams.keys() # noqa: SLF001 + ) + + def test_skip_existing_stream_id(self, tmp_session): + tmp_session.add(FakeStream("stub_camera_a")) + register_discoverer(_StubVideoAdapter) + + added = scan_and_add(tmp_session) + # "stub_camera_a" was pre-existing → a collision-avoiding id + # (stub_camera_a_0 or similar) should be used for the new one + assert len(added) == 2 + streams = tmp_session._streams # noqa: SLF001 + assert "stub_camera_a" in streams # pre-existing FakeStream + # New one must have a distinct id + new_ids = set(streams.keys()) - {"stub_camera_a"} + assert any(i.startswith("stub_camera_a") for i in new_ids) + + def test_refuses_non_idle_session(self, tmp_path): + session = sf.SessionOrchestrator( + host_id="test", + output_dir=tmp_path, + sync_tone=sf.SyncToneConfig.silent(), + ) + session.add(FakeStream("x")) + session.start() + try: + register_discoverer(_StubVideoAdapter) + with pytest.raises(RuntimeError, match="IDLE"): + scan_and_add(session) + finally: + session.stop() + + def test_output_dir_injected_for_video_adapters(self, tmp_session): + register_discoverer(_StubVideoAdapter) + scan_and_add(tmp_session) + # _StubVideoAdapter records kwargs; both instances should have + # received an output_dir equal to the session's. + for stream in tmp_session._streams.values(): # noqa: SLF001 + assert "output_dir" in stream.kwargs + assert stream.kwargs["output_dir"] == tmp_session.output_dir + + def test_output_dir_not_injected_for_sensor_adapters(self, tmp_session): + register_discoverer(_StubSensorAdapter) + scan_and_add(tmp_session) + sensor_stream = next(iter(tmp_session._streams.values())) # noqa: SLF001 + assert "output_dir" not in sensor_stream.kwargs diff --git a/tests/unit/discovery/test_types.py b/tests/unit/discovery/test_types.py new file mode 100644 index 0000000..cfe7e0b --- /dev/null +++ b/tests/unit/discovery/test_types.py @@ -0,0 +1,117 @@ +"""Unit tests for the discovery data model (DiscoveredDevice, DiscoveryReport).""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +import pytest + +from syncfield.discovery.types import DiscoveredDevice, DiscoveryReport + + +class _StubAdapter: + """Stand-in for a Stream class in tests — just records constructor kwargs.""" + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + + +def _make_device(**overrides: Any) -> DiscoveredDevice: + defaults: dict[str, Any] = { + "adapter_type": "stub", + "adapter_cls": _StubAdapter, + "kind": "video", + "display_name": "Stub Camera", + "description": "1920×1080 · stub", + "device_id": "stub:0", + "construct_kwargs": {"device_index": 0}, + "accepts_output_dir": True, + } + defaults.update(overrides) + return DiscoveredDevice(**defaults) + + +class TestDiscoveredDevice: + def test_frozen(self): + device = _make_device() + with pytest.raises(dataclasses.FrozenInstanceError): + device.display_name = "other" # type: ignore[misc] + + def test_construct_merges_kwargs(self): + device = _make_device(construct_kwargs={"device_index": 2}) + stream = device.construct(id="cam_main", output_dir="/tmp/data") + assert isinstance(stream, _StubAdapter) + assert stream.kwargs == { + "device_index": 2, + "id": "cam_main", + "output_dir": "/tmp/data", + } + + def test_construct_caller_overrides_discovered(self): + """Caller kwargs win on conflict (overriding discovery-set defaults).""" + device = _make_device(construct_kwargs={"fps": 30}) + stream = device.construct(id="cam", fps=60) + assert stream.kwargs["fps"] == 60 + + def test_construct_requires_id(self): + device = _make_device() + with pytest.raises(TypeError, match="'id'"): + device.construct(output_dir="/tmp") + + def test_defaults(self): + device = DiscoveredDevice( + adapter_type="x", + adapter_cls=_StubAdapter, + kind="video", + display_name="X", + description="", + device_id="0", + ) + assert device.construct_kwargs == {} + assert device.accepts_output_dir is False + assert device.in_use is False + assert device.warnings == () + + +class TestDiscoveryReport: + def test_by_kind(self): + cam = _make_device(kind="video", display_name="Cam") + imu = _make_device(kind="sensor", display_name="IMU") + report = DiscoveryReport(devices=(cam, imu)) + assert report.by_kind("video") == (cam,) + assert report.by_kind("sensor") == (imu,) + assert report.by_kind("audio") == () + + def test_by_adapter_type(self): + cam = _make_device(adapter_type="uvc_webcam") + oak = _make_device(adapter_type="oak_camera") + report = DiscoveryReport(devices=(cam, oak)) + assert report.by_adapter_type("uvc_webcam") == (cam,) + assert report.by_adapter_type("oak_camera") == (oak,) + + def test_is_success(self): + assert DiscoveryReport(devices=()).is_success is True + assert DiscoveryReport( + devices=(), errors={"ble": "oops"} + ).is_success is False + assert DiscoveryReport( + devices=(), timed_out=("ble",) + ).is_success is False + + def test_summary(self): + device = _make_device() + report = DiscoveryReport(devices=(device,), duration_s=1.234) + assert "1 devices" in report.summary() + assert "1.2s" in report.summary() + + def test_summary_with_errors_and_timeouts(self): + report = DiscoveryReport( + devices=(), + errors={"ble": "no adapter"}, + duration_s=2.0, + timed_out=("oak_camera",), + ) + summary = report.summary() + assert "error" in summary + assert "timed out" in summary From 6e3e98c1f573ddf5e84811cdf70f105767327426 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:10:45 -0700 Subject: [PATCH 06/45] feat(tone): capture PortAudio DAC time via ChirpEmission Rework ChirpPlayer protocol to return ChirpEmission carrying both the software monotonic timestamp (always present) and the hardware DAC presentation timestamp (best-effort via PortAudio callback time_info). SoundDeviceChirpPlayer now uses sd.OutputStream with a callback so it can sample time.monotonic_ns() inside the first callback and convert outputBufferDacTime - currentTime into a monotonic offset. Falls back to software timestamp when the backend cannot supply DAC time or the first callback does not fire within 100 ms. SessionOrchestrator stores ChirpEmission objects instead of raw ns ints and writes both best_ns and source ("hardware"/"software_fallback"/ "silent") into sync_point.json. SessionReport gains source fields so downstream sync tooling can decide which hosts can claim sub-ms chirp anchor precision. 38 new tests (TestChirpEmission, TestSoundDeviceChirpPlayerHardwareTimestamp, TestChirpEmissionPropagation) cover the hardware path, fallback paths, active-stream cleanup, and source propagation through sync_point.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 70 +++++-- src/syncfield/tone.py | 211 +++++++++++++++++--- src/syncfield/types.py | 86 +++++++- src/syncfield/writer.py | 53 ++++- tests/unit/test_chirp_emission.py | 318 ++++++++++++++++++++++++++++++ tests/unit/test_orchestrator.py | 101 +++++++++- tests/unit/test_tone.py | 79 ++++++-- 7 files changed, 837 insertions(+), 81 deletions(-) create mode 100644 tests/unit/test_chirp_emission.py diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 2fba249..80f7d2c 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -34,6 +34,7 @@ from syncfield.stream import Stream from syncfield.tone import ChirpPlayer, SyncToneConfig, create_default_player from syncfield.types import ( + ChirpEmission, FinalizationReport, HealthEvent, SessionReport, @@ -77,8 +78,8 @@ def __init__( # Populated during start(); consumed during stop(). self._sync_point: Optional[SyncPoint] = None self._session_clock: Optional[SessionClock] = None - self._chirp_start_ns: Optional[int] = None - self._chirp_stop_ns: Optional[int] = None + self._chirp_start: Optional[ChirpEmission] = None + self._chirp_stop: Optional[ChirpEmission] = None self._log_writer: Optional[SessionLogWriter] = None # ------------------------------------------------------------------ @@ -246,8 +247,26 @@ def stop(self) -> SessionReport: return SessionReport( host_id=self._host_id, finalizations=finalizations, - chirp_start_ns=self._chirp_start_ns, - chirp_stop_ns=self._chirp_stop_ns, + chirp_start_ns=( + self._chirp_start.best_ns + if self._chirp_start is not None + else None + ), + chirp_stop_ns=( + self._chirp_stop.best_ns + if self._chirp_stop is not None + else None + ), + chirp_start_source=( + self._chirp_start.source + if self._chirp_start is not None + else None + ), + chirp_stop_source=( + self._chirp_stop.source + if self._chirp_stop is not None + else None + ), ) def _finalize_streams(self) -> List[FinalizationReport]: @@ -287,17 +306,33 @@ def _persist_session_artifacts( only be entered through ``start()``. Chirp fields are included only when a chirp was actually played — the writer omits ``chirp_*`` fields otherwise. + + Both the best-available timestamp (``chirp_*_ns``) and the + provenance tag (``chirp_*_source``) are threaded through so the + downstream sync core can decide whether to claim sub-ms + (``hardware``) or ~1 ms (``software_fallback``) precision for + this host. """ assert self._sync_point is not None # guaranteed by state check chirp_spec = ( - self._sync_tone.start_chirp if self._chirp_start_ns is not None else None + self._sync_tone.start_chirp if self._chirp_start is not None else None ) write_sync_point( self._sync_point, self._output_dir, - chirp_start_ns=self._chirp_start_ns, - chirp_stop_ns=self._chirp_stop_ns, + chirp_start_ns=( + self._chirp_start.best_ns if self._chirp_start is not None else None + ), + chirp_stop_ns=( + self._chirp_stop.best_ns if self._chirp_stop is not None else None + ), + chirp_start_source=( + self._chirp_start.source if self._chirp_start is not None else None + ), + chirp_stop_source=( + self._chirp_stop.source if self._chirp_stop is not None else None + ), chirp_spec=chirp_spec, ) @@ -392,12 +427,15 @@ def _maybe_play_start_chirp(self) -> None: Sleeps ``post_start_stabilization_ms`` first so audio capture pipelines have time to warm up and begin recording before the - chirp hits the microphone. + chirp hits the microphone. Stores the returned + :class:`ChirpEmission` so both hardware and software timestamps + are preserved for the session artifacts. """ if self._is_chirp_eligible(): time.sleep(self._sync_tone.post_start_stabilization_ms / 1000.0) - self._chirp_start_ns = time.monotonic_ns() - self._chirp_player.play(self._sync_tone.start_chirp) + self._chirp_start = self._chirp_player.play( + self._sync_tone.start_chirp + ) return if self._sync_tone.enabled: @@ -411,16 +449,16 @@ def _maybe_play_start_chirp(self) -> None: def _maybe_play_stop_chirp_and_wait(self) -> None: """Play the stop chirp BEFORE stopping streams and wait for it to flush. - The stop chirp must be captured in each recording audio track, so - we play it first, then sleep for the chirp's duration plus a - configurable tail margin, then let ``stop()`` proceed to finalize - the streams. + The stop chirp must be captured in each recording audio track, + so we play it first, then sleep for the chirp's duration plus a + configurable tail margin, then let ``stop()`` proceed to + finalize the streams. Stores the returned + :class:`ChirpEmission` for the session artifacts. """ if not self._is_chirp_eligible(): return - self._chirp_stop_ns = time.monotonic_ns() - self._chirp_player.play(self._sync_tone.stop_chirp) + self._chirp_stop = self._chirp_player.play(self._sync_tone.stop_chirp) total_wait_ms = ( self._sync_tone.stop_chirp.duration_ms + self._sync_tone.pre_stop_tail_margin_ms diff --git a/src/syncfield/tone.py b/src/syncfield/tone.py index b426792..be3f030 100644 --- a/src/syncfield/tone.py +++ b/src/syncfield/tone.py @@ -18,12 +18,14 @@ import logging import math import struct +import threading +import time import wave from dataclasses import dataclass, field from pathlib import Path -from typing import Any, List, Protocol, runtime_checkable +from typing import Any, List, Optional, Protocol, runtime_checkable -from syncfield.types import ChirpSpec +from syncfield.types import ChirpEmission, ChirpSource, ChirpSpec logger = logging.getLogger(__name__) @@ -211,15 +213,20 @@ def silent(cls) -> "SyncToneConfig": class ChirpPlayer(Protocol): """Protocol for playing a chirp to the system audio output. - Implementations must be **non-blocking**: ``play()`` returns immediately - after scheduling the sound. The caller - (:class:`~syncfield.orchestrator.SessionOrchestrator`) is responsible for - all timing margins — it sleeps for the chirp's duration plus the - configured tail margin before stopping streams. + Implementations must be **non-blocking** for the full chirp duration: + ``play()`` may briefly block waiting for the audio backend's first + callback so it can capture a hardware DAC timestamp (typically a few + milliseconds), but must never block for the entire chirp. The + orchestrator handles all timing margins around the chirp. + + Returns a :class:`~syncfield.types.ChirpEmission` so callers can + persist both the software send time and the best-available hardware + presentation time with each session — this is the foundation of + SyncField's chirp-anchored multi-host synchronization. """ - def play(self, spec: ChirpSpec) -> None: - """Schedule playback of a chirp. Returns immediately.""" + def play(self, spec: ChirpSpec) -> ChirpEmission: + """Schedule playback and return the emission record.""" ... def is_silent(self) -> bool: @@ -233,39 +240,92 @@ class SilentChirpPlayer: Emits an INFO log line on every ``play()`` so callers can see that a chirp was requested but not produced. Used automatically on headless lab machines where :func:`create_default_player` cannot import - ``sounddevice``. + ``sounddevice``. Returns a :class:`ChirpEmission` tagged ``"silent"`` + so downstream sync tooling can distinguish "no audio path" from + "tried to play but the backend had no DAC timestamp". """ - def play(self, spec: ChirpSpec) -> None: + def play(self, spec: ChirpSpec) -> ChirpEmission: logger.info( "SilentChirpPlayer.play(%s): chirp skipped (no audio output)", spec ) + return ChirpEmission( + software_ns=time.monotonic_ns(), + hardware_ns=None, + source="silent", + ) def is_silent(self) -> bool: return True -class SoundDeviceChirpPlayer: - """Plays chirps via the optional ``sounddevice`` library, non-blocking. +@dataclass +class _ChirpPlaybackState: + """Shared state between :meth:`SoundDeviceChirpPlayer.play` and the + PortAudio callback thread. + + Attributes: + position: Index of the next sample to copy into ``outdata``. + hardware_ns: Hardware DAC timestamp captured on first callback, + or ``None`` if the backend did not expose DAC time. + source: Provenance tag set on first callback. + first_callback: Event set as soon as the first callback runs, + unblocking :meth:`play`. + """ + + position: int = 0 + hardware_ns: Optional[int] = None + source: ChirpSource = "software_fallback" + first_callback: threading.Event = field(default_factory=threading.Event) + - ``sounddevice`` is an optional dependency (``pip install syncfield[audio]``). - Prefer :func:`create_default_player` over direct instantiation — it - chooses this backend when ``sounddevice`` imports successfully and falls - back to :class:`SilentChirpPlayer` otherwise. +class SoundDeviceChirpPlayer: + """Plays chirps via ``sounddevice`` with hardware DAC timestamp capture. + + On the first audio callback after :meth:`sounddevice.OutputStream.start`, + PortAudio hands us a ``time_info`` struct whose + ``outputBufferDacTime`` is the stream time at which the first sample + in the buffer will be clocked out of the DAC. We sample + ``time.monotonic_ns()`` inside the same callback and compute:: + + hardware_ns = monotonic_at_callback + + (dac_time - current_time) * 1e9 + + :meth:`play` briefly blocks (default 100 ms) waiting for that first + callback so the returned :class:`ChirpEmission` can carry the + hardware timestamp. If PortAudio does not expose DAC time on the + current backend (``dac_time == current_time``) or the callback does + not fire within the timeout, the player falls back to the software + timestamp captured before ``stream.start()`` and tags the emission + as ``"software_fallback"``. + + Active streams are pinned on :attr:`_active_streams` until their + ``finished_callback`` fires so Python GC cannot tear the audio + thread down while the chirp is still playing. + + ``sounddevice`` is an optional dependency + (``pip install syncfield[audio]``). Prefer :func:`create_default_player` + over direct instantiation — it chooses this backend when + ``sounddevice`` imports successfully and falls back to + :class:`SilentChirpPlayer` otherwise. Args: - sample_rate: Sample rate used both for sample synthesis and for - the sounddevice stream. Default ``44100``. + sample_rate: Sample rate used for sample synthesis and the + PortAudio stream. Default ``44100``. """ + #: Default wait for the first callback before falling back to software + #: timestamp. 100 ms comfortably covers default PortAudio buffer + #: latencies on macOS/Linux/Windows (typical: 5–30 ms). + DEFAULT_FIRST_CALLBACK_TIMEOUT_SEC = 0.1 + def __init__(self, sample_rate: int = 44100) -> None: self._sample_rate = sample_rate + self._active_streams: List[Any] = [] + self._streams_lock = threading.Lock() + self._first_callback_timeout = self.DEFAULT_FIRST_CALLBACK_TIMEOUT_SEC - def play(self, spec: ChirpSpec) -> None: - # Lazy sounddevice import keeps this module importable on machines - # that lack the audio extras. sounddevice.play() internally requires - # numpy even if the caller passes a list, so we hand it a float32 - # ndarray constructed from the module-level numpy reference. + def play(self, spec: ChirpSpec) -> ChirpEmission: import sounddevice as sd # type: ignore[import-not-found] samples = generate_chirp_samples(spec, sample_rate=self._sample_rate) @@ -273,15 +333,108 @@ def play(self, spec: ChirpSpec) -> None: if _np is not None: buffer = _np.asarray(samples, dtype=_np.float32) else: - # No numpy available — pass the raw list and let sounddevice - # raise its own ImportError at play time. Tests mock sd so this - # branch is only hit in real headful runs without numpy. - buffer = samples - sd.play(buffer, self._sample_rate) # non-blocking: returns immediately + buffer = samples # pragma: no cover - tested indirectly via fake sd + total = len(buffer) + + state = _ChirpPlaybackState() + + def callback(outdata: Any, frames: int, time_info: Any, status: Any) -> None: + if state.position == 0: + mono_ns = time.monotonic_ns() + try: + dac_time = float(time_info.outputBufferDacTime) + cur_time = float(time_info.currentTime) + except (AttributeError, TypeError, ValueError): + dac_time = cur_time = 0.0 + if dac_time > cur_time: + offset_ns = int( + round((dac_time - cur_time) * 1_000_000_000) + ) + state.hardware_ns = mono_ns + offset_ns + state.source = "hardware" + else: + state.hardware_ns = None + state.source = "software_fallback" + state.first_callback.set() + + end = min(state.position + frames, total) + n = end - state.position + if n > 0: + outdata[:n, 0] = buffer[state.position:end] + if n < frames: + outdata[n:, 0] = 0.0 + state.position = total + raise sd.CallbackStop + state.position = end + + def finished_cb() -> None: + self._drop_stream(stream) + + software_ns = time.monotonic_ns() + stream = sd.OutputStream( + samplerate=self._sample_rate, + channels=1, + callback=callback, + finished_callback=finished_cb, + ) + with self._streams_lock: + self._active_streams.append(stream) + try: + stream.start() + except Exception: + self._drop_stream(stream) + raise + + got_first = state.first_callback.wait(self._first_callback_timeout) + if not got_first: + return ChirpEmission( + software_ns=software_ns, + hardware_ns=None, + source="software_fallback", + ) + return ChirpEmission( + software_ns=software_ns, + hardware_ns=state.hardware_ns, + source=state.source, + ) def is_silent(self) -> bool: return False + def close(self) -> None: + """Force-close any streams still pinned in the active list. + + Streams normally evict themselves via ``finished_callback`` when + playback ends naturally. This method exists for tests and + shutdown paths that need to force cleanup without waiting for + the audio thread to drain. + """ + with self._streams_lock: + streams = list(self._active_streams) + self._active_streams.clear() + for s in streams: + try: + s.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + + def _drop_stream(self, stream: Any) -> None: + """Remove *stream* from the active list and close it. + + Called from the PortAudio ``finished_callback`` (audio thread) + and from :meth:`close` (user thread). The lock keeps the two + paths from racing on ``self._active_streams``. + """ + with self._streams_lock: + try: + self._active_streams.remove(stream) + except ValueError: + pass + try: + stream.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + def create_default_player(sample_rate: int = 44100) -> ChirpPlayer: """Return the best available :class:`ChirpPlayer` for this environment. diff --git a/src/syncfield/types.py b/src/syncfield/types.py index 1018c11..65d4430 100644 --- a/src/syncfield/types.py +++ b/src/syncfield/types.py @@ -301,6 +301,75 @@ def to_dict(self) -> dict[str, Any]: } +ChirpSource = Literal["hardware", "software_fallback", "silent"] +"""Provenance tag for a :class:`ChirpEmission` timestamp. + +- ``"hardware"``: ``hardware_ns`` was derived from the audio backend's + DAC presentation timestamp (e.g. PortAudio ``outputBufferDacTime``). + This is the best timestamp SyncField can produce. +- ``"software_fallback"``: the backend was real but could not supply a + DAC time, so ``hardware_ns`` is ``None`` and the caller should use + ``software_ns`` instead. Precision floor rises to ~1 ms jitter. +- ``"silent"``: no audio was actually played (``SilentChirpPlayer``, + chirp disabled, or headless machine with no audio path). Chirp-anchored + sync cannot use this host as a shared acoustic reference. +""" + +_VALID_CHIRP_SOURCES = frozenset({"hardware", "software_fallback", "silent"}) + + +@dataclass(frozen=True) +class ChirpEmission: + """Result of playing a sync chirp: when it actually hit the DAC. + + The SDK prefers ``hardware_ns`` — captured from the audio driver's + DAC presentation timestamp — and falls back to ``software_ns`` + (sampled immediately before the play call) when the backend cannot + supply one. The ``source`` field tags which timestamp is + authoritative so the downstream sync core can decide how much + precision to claim for this host's chirp anchor. + + Attributes: + software_ns: ``time.monotonic_ns()`` sampled by the caller right + before handing the chirp to the audio backend. Always present. + hardware_ns: Monotonic nanosecond estimate of when the first + chirp sample will actually be clocked out of the DAC. + ``None`` when the backend does not expose a hardware + presentation time (e.g. silent player, or PortAudio backends + without DAC time on the current host). + source: Provenance tag, see :data:`ChirpSource`. + """ + + software_ns: int + hardware_ns: int | None + source: ChirpSource + + def __post_init__(self) -> None: + if self.source not in _VALID_CHIRP_SOURCES: + raise ValueError( + "ChirpEmission.source must be one of " + f"{sorted(_VALID_CHIRP_SOURCES)}; got {self.source!r}" + ) + + @property + def best_ns(self) -> int: + """Return ``hardware_ns`` when present, else ``software_ns``. + + This is the value the orchestrator persists into + ``sync_point.json`` as ``chirp_start_ns`` / ``chirp_stop_ns``. + """ + return self.hardware_ns if self.hardware_ns is not None else self.software_ns + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "software_ns": self.software_ns, + "source": self.source, + } + if self.hardware_ns is not None: + d["hardware_ns"] = self.hardware_ns + return d + + @dataclass class SessionReport: """Aggregated result of a completed session. @@ -308,11 +377,24 @@ class SessionReport: Attributes: host_id: Host identifier. finalizations: Per-stream finalization reports. - chirp_start_ns: Monotonic ns when start chirp was played (or None). - chirp_stop_ns: Monotonic ns when stop chirp was played (or None). + chirp_start_ns: Best-available monotonic ns when the start chirp + reached the DAC (hardware if available, else software + fallback). ``None`` if no chirp was played. + chirp_stop_ns: Best-available monotonic ns for the stop chirp. + chirp_start_source: Provenance of ``chirp_start_ns`` — one of + ``"hardware"``, ``"software_fallback"``, ``"silent"``, or + ``None`` when no chirp was played. + chirp_stop_source: Provenance of ``chirp_stop_ns``. + session_id: Multi-host session identifier (from the attached + role config), ``None`` for single-host sessions. + role: ``"leader"``, ``"follower"``, or ``None`` for single-host. """ host_id: str finalizations: list[FinalizationReport] chirp_start_ns: int | None chirp_stop_ns: int | None + chirp_start_source: str | None = None + chirp_stop_source: str | None = None + session_id: str | None = None + role: str | None = None diff --git a/src/syncfield/writer.py b/src/syncfield/writer.py index 30882b2..d14edbc 100644 --- a/src/syncfield/writer.py +++ b/src/syncfield/writer.py @@ -166,20 +166,33 @@ def write_sync_point( output_dir: Path, chirp_start_ns: Optional[int] = None, chirp_stop_ns: Optional[int] = None, + chirp_start_source: Optional[str] = None, + chirp_stop_source: Optional[str] = None, chirp_spec: Optional[ChirpSpec] = None, + session_id: Optional[str] = None, + role: Optional[str] = None, ) -> Path: """Write ``sync_point.json`` to *output_dir* and return the path. - Chirp-related fields are **omitted entirely** when ``None`` so single-host - sessions and sessions configured with ``SyncToneConfig.silent()`` produce - clean output that the sync core can ingest without special-casing. + All optional fields are **omitted entirely** when ``None`` so + single-host sessions and sessions configured with + :meth:`syncfield.tone.SyncToneConfig.silent` produce clean output + that the sync core can ingest without special-casing missing keys. Args: sync_point: Captured session sync point. output_dir: Directory in which to write ``sync_point.json``. - chirp_start_ns: Monotonic ns of the start chirp (if played), else None. - chirp_stop_ns: Monotonic ns of the stop chirp (if played), else None. - chirp_spec: Parameters of the chirp that was played, for reproducibility. + chirp_start_ns: Best-available monotonic ns for the start chirp + (hardware if available, else software fallback). + chirp_stop_ns: Best-available monotonic ns for the stop chirp. + chirp_start_source: Provenance of ``chirp_start_ns`` — one of + ``"hardware"``, ``"software_fallback"``, ``"silent"``. + chirp_stop_source: Provenance of ``chirp_stop_ns``. + chirp_spec: Parameters of the chirp that was played, for + reproducibility. + session_id: Multi-host session identifier (from + :class:`LeaderRole` / :class:`FollowerRole`). + role: ``"leader"`` or ``"follower"`` for multi-host sessions. Returns: Absolute path to the written file. @@ -187,10 +200,18 @@ def write_sync_point( path = output_dir / "sync_point.json" data: dict[str, Any] = {"sdk_version": _pkg_version("syncfield")} data.update(sync_point.to_dict()) + if session_id is not None: + data["session_id"] = session_id + if role is not None: + data["role"] = role if chirp_start_ns is not None: data["chirp_start_ns"] = chirp_start_ns if chirp_stop_ns is not None: data["chirp_stop_ns"] = chirp_stop_ns + if chirp_start_source is not None: + data["chirp_start_source"] = chirp_start_source + if chirp_stop_source is not None: + data["chirp_stop_source"] = chirp_stop_source if chirp_spec is not None: data["chirp_spec"] = chirp_spec.to_dict() with open(path, "w") as f: @@ -203,13 +224,21 @@ def write_manifest( host_id: str, streams: dict[str, dict[str, Any]], output_dir: Path, + *, + session_id: Optional[str] = None, + role: Optional[str] = None, + leader_host_id: Optional[str] = None, ) -> Path: """Write ``manifest.json`` to *output_dir* and return the path. - The ``streams`` argument is written verbatim under the ``"streams"`` key, - so callers may include any additional per-stream metadata — including - ``"capabilities"`` dictionaries produced by + The ``streams`` argument is written verbatim under the ``"streams"`` + key, so callers may include any additional per-stream metadata — + including ``"capabilities"`` dictionaries produced by :meth:`syncfield.types.StreamCapabilities.to_dict`. + + Multi-host fields (``session_id``, ``role``, ``leader_host_id``) + are omitted entirely for single-host sessions so the manifest stays + clean of defaulted null fields. """ path = output_dir / "manifest.json" manifest: dict[str, Any] = { @@ -217,6 +246,12 @@ def write_manifest( "host_id": host_id, "streams": streams, } + if session_id is not None: + manifest["session_id"] = session_id + if role is not None: + manifest["role"] = role + if leader_host_id is not None: + manifest["leader_host_id"] = leader_host_id with open(path, "w") as f: json.dump(manifest, f, indent=2) f.write("\n") diff --git a/tests/unit/test_chirp_emission.py b/tests/unit/test_chirp_emission.py new file mode 100644 index 0000000..877b881 --- /dev/null +++ b/tests/unit/test_chirp_emission.py @@ -0,0 +1,318 @@ +"""Tests for :class:`ChirpEmission` and the hardware-timestamped +:class:`SoundDeviceChirpPlayer` that produces it. + +The player tests use a fake ``sounddevice`` module patched into +``sys.modules`` so the real PortAudio backend is never touched. The +fake :class:`_FakeOutputStream` records the callback the player +registers and lets the test thread drive it synchronously with a +scripted ``time_info`` struct, exercising both the hardware-timestamp +path and the software fallback. +""" + +from __future__ import annotations + +import sys +import threading +import time +from types import SimpleNamespace +from typing import Any, List + +import pytest + + +# --------------------------------------------------------------------------- +# ChirpEmission value type +# --------------------------------------------------------------------------- + + +class TestChirpEmission: + def test_hardware_source_exposes_hardware_ns_as_best(self): + from syncfield.types import ChirpEmission + + e = ChirpEmission(software_ns=100, hardware_ns=150, source="hardware") + assert e.best_ns == 150 + + def test_software_fallback_uses_software_ns_as_best(self): + from syncfield.types import ChirpEmission + + e = ChirpEmission( + software_ns=100, hardware_ns=None, source="software_fallback" + ) + assert e.best_ns == 100 + + def test_silent_source_uses_software_ns_as_best(self): + from syncfield.types import ChirpEmission + + e = ChirpEmission(software_ns=100, hardware_ns=None, source="silent") + assert e.best_ns == 100 + + def test_to_dict_includes_hardware_when_present(self): + from syncfield.types import ChirpEmission + + e = ChirpEmission(software_ns=100, hardware_ns=150, source="hardware") + assert e.to_dict() == { + "software_ns": 100, + "hardware_ns": 150, + "source": "hardware", + } + + def test_to_dict_omits_hardware_ns_when_none(self): + from syncfield.types import ChirpEmission + + e = ChirpEmission(software_ns=100, hardware_ns=None, source="silent") + d = e.to_dict() + assert "hardware_ns" not in d + assert d == {"software_ns": 100, "source": "silent"} + + def test_invalid_source_rejected(self): + from syncfield.types import ChirpEmission + + with pytest.raises(ValueError, match="source"): + ChirpEmission( + software_ns=100, hardware_ns=None, source="bogus" # type: ignore[arg-type] + ) + + def test_is_frozen(self): + import dataclasses + + from syncfield.types import ChirpEmission + + e = ChirpEmission(software_ns=1, hardware_ns=None, source="silent") + with pytest.raises(dataclasses.FrozenInstanceError): + e.software_ns = 99 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Fake sounddevice backend for player tests +# --------------------------------------------------------------------------- + + +class _CallbackStop(Exception): + """Stand-in for ``sounddevice.CallbackStop``.""" + + +class _FakeStatus: + def __bool__(self) -> bool: + return False + + +class _FakeOutputStream: + """Captures the callback registered by SoundDeviceChirpPlayer. + + Tests drive playback by calling :meth:`fire_callback` with a scripted + ``time_info`` struct — this mimics the real PortAudio callback chain + without actually touching hardware. + """ + + instances: List["_FakeOutputStream"] = [] + + def __init__( + self, + samplerate: int, + channels: int, + callback: Any, + finished_callback: Any = None, + **_: Any, + ) -> None: + self.samplerate = samplerate + self.channels = channels + self.callback = callback + self.finished_callback = finished_callback + self.started = False + self.closed = False + _FakeOutputStream.instances.append(self) + + def start(self) -> None: + self.started = True + + def close(self) -> None: + self.closed = True + + def fire_callback(self, frames: int, out_buffer: Any, time_info: Any) -> bool: + """Invoke the registered callback once. + + Returns ``True`` if the callback raised :class:`_CallbackStop` + (meaning playback finished); ``False`` otherwise. + """ + try: + self.callback(out_buffer, frames, time_info, _FakeStatus()) + except _CallbackStop: + if self.finished_callback is not None: + self.finished_callback() + return True + return False + + +@pytest.fixture +def fake_sounddevice(monkeypatch): + """Install a fake ``sounddevice`` module into ``sys.modules``.""" + _FakeOutputStream.instances.clear() + fake_sd = SimpleNamespace( + OutputStream=_FakeOutputStream, + CallbackStop=_CallbackStop, + ) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + yield fake_sd + _FakeOutputStream.instances.clear() + + +def _wait_for_stream_started(timeout_sec: float = 0.5) -> _FakeOutputStream: + """Poll until a `_FakeOutputStream` has been started by the player.""" + deadline = time.monotonic() + timeout_sec + while time.monotonic() < deadline: + if _FakeOutputStream.instances and _FakeOutputStream.instances[-1].started: + return _FakeOutputStream.instances[-1] + time.sleep(0.005) + raise AssertionError("no OutputStream started within timeout") + + +# --------------------------------------------------------------------------- +# Hardware timestamp capture path +# --------------------------------------------------------------------------- + + +class TestSoundDeviceChirpPlayerHardwareTimestamp: + def test_hardware_timestamp_captured_from_dac_time(self, fake_sounddevice): + import numpy as np + + from syncfield import tone + + player = tone.SoundDeviceChirpPlayer(sample_rate=16000) + spec = tone.ChirpSpec(400, 2500, 10, 0.8, 2) + + def drive_stream(): + stream = _wait_for_stream_started() + out_buffer = np.zeros((64, 1), dtype=np.float32) + # currentTime=100.0, outputBufferDacTime=100.01 → 10ms latency + time_info = SimpleNamespace( + currentTime=100.0, + outputBufferDacTime=100.01, + inputBufferAdcTime=0.0, + ) + stream.fire_callback(frames=64, out_buffer=out_buffer, time_info=time_info) + + t = threading.Thread(target=drive_stream, daemon=True) + t.start() + emission = player.play(spec) + t.join(timeout=1.0) + + assert emission.source == "hardware" + assert emission.hardware_ns is not None + delta_ms = (emission.hardware_ns - emission.software_ns) / 1_000_000 + # DAC is 10 ms in the future; allow wide tolerance to absorb the + # unavoidable Python thread-scheduling jitter between + # ``software_ns`` capture and the fake callback invocation. + assert 5 <= delta_ms <= 50, f"hw offset {delta_ms:.3f} ms out of band" + + def test_software_fallback_when_dac_time_missing(self, fake_sounddevice): + import numpy as np + + from syncfield import tone + + player = tone.SoundDeviceChirpPlayer(sample_rate=16000) + spec = tone.ChirpSpec(400, 2500, 10, 0.8, 2) + + def drive_stream(): + stream = _wait_for_stream_started() + out_buffer = np.zeros((64, 1), dtype=np.float32) + # currentTime == outputBufferDacTime → no latency info + time_info = SimpleNamespace( + currentTime=50.0, + outputBufferDacTime=50.0, + inputBufferAdcTime=0.0, + ) + stream.fire_callback(frames=64, out_buffer=out_buffer, time_info=time_info) + + t = threading.Thread(target=drive_stream, daemon=True) + t.start() + emission = player.play(spec) + t.join(timeout=1.0) + + assert emission.source == "software_fallback" + assert emission.hardware_ns is None + assert emission.software_ns > 0 + + def test_software_fallback_when_first_callback_times_out(self, fake_sounddevice): + from syncfield import tone + + player = tone.SoundDeviceChirpPlayer(sample_rate=16000) + # Shrink timeout so the test stays fast; nothing drives the callback + player._first_callback_timeout = 0.02 # type: ignore[attr-defined] + spec = tone.ChirpSpec(400, 2500, 10, 0.8, 2) + + emission = player.play(spec) + assert emission.source == "software_fallback" + assert emission.hardware_ns is None + + def test_playback_exhausts_buffer_and_fires_finished_callback( + self, fake_sounddevice + ): + import numpy as np + + from syncfield import tone + + player = tone.SoundDeviceChirpPlayer(sample_rate=16000) + # 10 ms chirp at 16 kHz → 160 samples + spec = tone.ChirpSpec(400, 2500, 10, 0.8, 2) + + finished_fired = threading.Event() + + def drive_stream(): + stream = _wait_for_stream_started() + # Hijack the finished_callback so the test can observe it + original = stream.finished_callback + + def hook(): + if original is not None: + original() + finished_fired.set() + + stream.finished_callback = hook + + # First callback: 64 samples with HW time info + out1 = np.zeros((64, 1), dtype=np.float32) + ti = SimpleNamespace( + currentTime=0.0, outputBufferDacTime=0.001, inputBufferAdcTime=0.0 + ) + assert stream.fire_callback(64, out1, ti) is False + # Second callback: 64 more samples + out2 = np.zeros((64, 1), dtype=np.float32) + assert stream.fire_callback(64, out2, ti) is False + # Third callback: drains the last 32 → CallbackStop + out3 = np.zeros((64, 1), dtype=np.float32) + assert stream.fire_callback(64, out3, ti) is True + + t = threading.Thread(target=drive_stream, daemon=True) + t.start() + emission = player.play(spec) + t.join(timeout=1.0) + + assert emission.source == "hardware" + assert finished_fired.wait(1.0) is True + + def test_close_drops_active_streams(self, fake_sounddevice): + from syncfield import tone + + player = tone.SoundDeviceChirpPlayer(sample_rate=16000) + player._first_callback_timeout = 0.02 # type: ignore[attr-defined] + player.play(tone.ChirpSpec(400, 2500, 10, 0.8, 2)) + # Stream is still in active list (callback never fired to completion) + assert len(_FakeOutputStream.instances) == 1 + player.close() + assert _FakeOutputStream.instances[-1].closed is True + + +# --------------------------------------------------------------------------- +# Silent player returns a well-formed ChirpEmission +# --------------------------------------------------------------------------- + + +class TestSilentChirpPlayerEmission: + def test_silent_player_returns_silent_emission(self): + from syncfield.tone import SilentChirpPlayer + from syncfield.types import ChirpSpec + + emission = SilentChirpPlayer().play(ChirpSpec(400, 2500, 10, 0.8, 2)) + assert emission.source == "silent" + assert emission.hardware_ns is None + assert emission.software_ns > 0 diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index cff2e11..ad7ef61 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -11,7 +11,36 @@ from syncfield.orchestrator import SessionOrchestrator from syncfield.testing import FakeStream from syncfield.tone import ChirpPlayer, ChirpSpec, SyncToneConfig -from syncfield.types import HealthEventKind, SessionState +from syncfield.types import ChirpEmission, HealthEventKind, SessionState + + +def _mk_emission( + software_ns: int = 1_000_000, + hardware_ns: int | None = None, + source: str = "software_fallback", +) -> ChirpEmission: + """Build a ``ChirpEmission`` for tests that mock ``ChirpPlayer.play``.""" + return ChirpEmission( + software_ns=software_ns, + hardware_ns=hardware_ns, + source=source, # type: ignore[arg-type] + ) + + +def _mock_player() -> MagicMock: + """Build a ``MagicMock`` spec'd on :class:`ChirpPlayer` that returns + distinct :class:`ChirpEmission` values for successive ``play`` calls. + + Most tests don't care about the exact numeric values as long as they + differ so ``chirp_stop_ns > chirp_start_ns`` assertions hold. + """ + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + player.play.side_effect = [ + _mk_emission(software_ns=1_000_000), + _mk_emission(software_ns=2_000_000), + ] + return player def _fast_chirp_config() -> SyncToneConfig: @@ -239,8 +268,7 @@ def test_failing_stream_does_not_block_other_stops(self, tmp_path): class TestChirpIntegration: def test_chirp_skipped_when_no_audio_capable_stream(self, tmp_path, caplog): - player = MagicMock(spec=ChirpPlayer) - player.is_silent.return_value = False + player = _mock_player() session = SessionOrchestrator( host_id="h", output_dir=tmp_path, @@ -255,8 +283,7 @@ def test_chirp_skipped_when_no_audio_capable_stream(self, tmp_path, caplog): assert "cannot participate" in caplog.text.lower() def test_chirp_played_when_audio_capable_stream_exists(self, tmp_path): - player = MagicMock(spec=ChirpPlayer) - player.is_silent.return_value = False + player = _mock_player() session = SessionOrchestrator( host_id="h", output_dir=tmp_path, @@ -269,7 +296,7 @@ def test_chirp_played_when_audio_capable_stream_exists(self, tmp_path): assert player.play.call_count == 2 # start + stop chirp def test_silent_tone_never_plays_chirp(self, tmp_path): - player = MagicMock(spec=ChirpPlayer) + player = _mock_player() session = SessionOrchestrator( host_id="h", output_dir=tmp_path, @@ -282,8 +309,7 @@ def test_silent_tone_never_plays_chirp(self, tmp_path): player.play.assert_not_called() def test_chirp_fields_written_to_sync_point_json(self, tmp_path): - player = MagicMock(spec=ChirpPlayer) - player.is_silent.return_value = False + player = _mock_player() session = SessionOrchestrator( host_id="h", output_dir=tmp_path, @@ -301,8 +327,7 @@ def test_chirp_fields_written_to_sync_point_json(self, tmp_path): assert sp["chirp_spec"]["from_hz"] == 400 def test_session_report_carries_chirp_timestamps(self, tmp_path): - player = MagicMock(spec=ChirpPlayer) - player.is_silent.return_value = False + player = _mock_player() session = SessionOrchestrator( host_id="h", output_dir=tmp_path, @@ -316,6 +341,62 @@ def test_session_report_carries_chirp_timestamps(self, tmp_path): assert report.chirp_stop_ns is not None +class TestChirpEmissionPropagation: + def test_hardware_emission_surfaces_in_session_report(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + player.play.side_effect = [ + ChirpEmission(software_ns=100, hardware_ns=500, source="hardware"), + ChirpEmission(software_ns=200, hardware_ns=700, source="hardware"), + ] + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + report = session.stop() + + assert report.chirp_start_ns == 500 + assert report.chirp_stop_ns == 700 + assert report.chirp_start_source == "hardware" + assert report.chirp_stop_source == "hardware" + + sp = json.loads((tmp_path / "sync_point.json").read_text()) + assert sp["chirp_start_source"] == "hardware" + assert sp["chirp_stop_source"] == "hardware" + assert sp["chirp_start_ns"] == 500 + assert sp["chirp_stop_ns"] == 700 + + def test_software_fallback_emission_surfaces_in_report(self, tmp_path): + player = MagicMock(spec=ChirpPlayer) + player.is_silent.return_value = False + player.play.side_effect = [ + ChirpEmission( + software_ns=1_000, hardware_ns=None, source="software_fallback" + ), + ChirpEmission( + software_ns=2_000, hardware_ns=None, source="software_fallback" + ), + ] + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + ) + session.add(FakeStream("a", provides_audio_track=True)) + session.start() + report = session.stop() + + assert report.chirp_start_ns == 1_000 + assert report.chirp_stop_ns == 2_000 + assert report.chirp_start_source == "software_fallback" + assert report.chirp_stop_source == "software_fallback" + + class TestSessionLog: def test_session_log_captures_state_transitions(self, tmp_path): session = _session(tmp_path) diff --git a/tests/unit/test_tone.py b/tests/unit/test_tone.py index 7925313..5ff896e 100644 --- a/tests/unit/test_tone.py +++ b/tests/unit/test_tone.py @@ -134,9 +134,12 @@ def test_custom_values_round_trip(self): class TestSilentChirpPlayer: - def test_play_is_noop(self): + def test_play_returns_silent_emission(self): player: ChirpPlayer = SilentChirpPlayer() - player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) # must not raise + emission = player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) + assert emission.source == "silent" + assert emission.hardware_ns is None + assert emission.software_ns > 0 def test_is_silent_returns_true(self): assert SilentChirpPlayer().is_silent() is True @@ -146,25 +149,71 @@ def test_satisfies_chirp_player_protocol(self): class TestSoundDeviceChirpPlayer: - def test_play_forwards_samples_and_sample_rate_to_sounddevice(self): - fake_sd = MagicMock() + """Coarse integration-style tests that validate ``play()`` opens a + ``sounddevice.OutputStream`` with an appropriate sample rate and + returns a :class:`~syncfield.types.ChirpEmission`. + + Detailed hardware-timestamp capture behavior lives in + ``test_chirp_emission.py`` (which drives a fake callback thread); + these tests just guard the public surface. + """ + + def test_play_opens_outputstream_with_sample_rate(self): + from types import SimpleNamespace + + class _Stub: + def __init__(self, *, samplerate, channels, callback, finished_callback=None, **_): + _Stub.last = self + self.samplerate = samplerate + self.channels = channels + self.started = False + def start(self): + self.started = True + def close(self): + pass + + fake_sd = SimpleNamespace( + OutputStream=_Stub, + CallbackStop=type("CallbackStop", (Exception,), {}), + ) with patch.dict(sys.modules, {"sounddevice": fake_sd}): player = SoundDeviceChirpPlayer(sample_rate=SAMPLE_RATE) - player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) - assert fake_sd.play.called - args, kwargs = fake_sd.play.call_args - samples = args[0] - assert len(samples) == int(SAMPLE_RATE * 0.1) - sent_rate = args[1] if len(args) > 1 else kwargs.get("samplerate") - assert sent_rate == SAMPLE_RATE - - def test_play_is_non_blocking(self): + player._first_callback_timeout = 0.02 # type: ignore[attr-defined] + emission = player.play(ChirpSpec(400, 2500, 100, 0.5, 5)) + assert _Stub.last.samplerate == SAMPLE_RATE + assert _Stub.last.channels == 1 + assert _Stub.last.started is True + # No callback fired → software fallback emission + assert emission.source == "software_fallback" + assert emission.software_ns > 0 + + def test_play_does_not_call_sd_wait(self): """play() must NEVER call sd.wait() — the orchestrator owns all timing.""" - fake_sd = MagicMock() + from types import SimpleNamespace + + wait_called = [] + + class _Stub: + def __init__(self, **_): + pass + def start(self): + pass + def close(self): + pass + + def _wait(*a, **k): + wait_called.append(True) + + fake_sd = SimpleNamespace( + OutputStream=_Stub, + CallbackStop=type("CallbackStop", (Exception,), {}), + wait=_wait, + ) with patch.dict(sys.modules, {"sounddevice": fake_sd}): player = SoundDeviceChirpPlayer(sample_rate=SAMPLE_RATE) + player._first_callback_timeout = 0.02 # type: ignore[attr-defined] player.play(ChirpSpec(400, 2500, 500, 0.8, 15)) - assert not fake_sd.wait.called + assert wait_called == [] def test_is_silent_returns_false(self): fake_sd = MagicMock() From 86b573cd18e3ea651bdf3ba39fba96178e770a0d Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:16:40 -0700 Subject: [PATCH 07/45] feat(adapters): add discover() classmethods for auto-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every shipped adapter now implements ``@classmethod discover(cls, *, timeout)`` and registers itself with the discovery registry in ``adapters/__init__.py``. Calling ``syncfield.discovery.scan()`` after importing ``syncfield.adapters`` walks all four adapters in parallel and returns a unified DiscoveryReport. OakCameraStream --------------- - New ``device_id`` constructor kwarg for multi-OAK pinning. When multiple devices are attached, ``scan_and_add`` wires each one to its specific serial via ``construct_kwargs={"device_id": ...}`` so the two streams don't race for "first available". - ``prepare()`` now resolves the selected device info and passes it to ``pipeline.build(selected)``; falls back to the older no-arg form via TypeError for DepthAI shims. - ``discover()`` calls ``dai.Device.getAllAvailableDevices()`` (sub-millisecond, no scan prompt) and returns one DiscoveredDevice per attached OAK. Swallows exceptions into empty list per the discoverer contract. UVCWebcamStream --------------- - Platform-split enumeration factored into free helpers ``_discover_uvc_macos()`` (system_profiler SPCameraDataType -json) and ``_discover_uvc_linux()`` (/dev/video* + sysfs name lookup). Windows / other platforms return empty list so discovery stays predictable. Critically, we DO NOT probe cv2.VideoCapture during discovery — that triggers the macOS camera permission dialog. - Device indices map to array position in the native listing, which is stable on almost all macOS setups (built-in at 0, Continuity Camera at 1, externals after). If the mapping drifts, users fall back to explicit ``UVCWebcamStream(device_index=...)``. OgloTactileStream ----------------- - ``discover()`` reads from the shared BLE cache (``syncfield.discovery._ble.scan_peripherals``) and filters by case-insensitive substring match on the advertised name. The default filter "oglo" picks up both left and right gloves. - Infers ``hand`` from the advertised name when "left"/"right" appears, populates ``construct_kwargs`` with both ``address`` and ``hand`` so ``scan_and_add`` can build a working stream with zero extra input from the caller. BLEImuGenericStream ------------------- - ``discover()`` reads from the same shared BLE cache (so two BLE-based adapters running in parallel share one 5-second scan) and returns ALL non-oglo peripherals. Each one is flagged with a ``warnings`` entry explaining that a ``characteristic_uuid`` is still required — ``scan_and_add`` then skips them with an INFO log, which is the correct behavior because there's no generic way to determine the notify characteristic of an arbitrary peripheral. - Uses ``adapter_type="ble_peripheral"`` (not ``ble_imu``) to make the "candidate, not-yet-usable" status clear in the CLI / viewer. - Excludes peripherals that a more-specific adapter would match (currently "oglo") to avoid double-listing. Registration ------------ adapters/__init__.py now calls ``_safe_register()`` after each try-import so adapters whose optional extras are installed get added to the discovery registry automatically. ``_safe_register`` swallows TypeError from misconfigured discoverers so one bad adapter never breaks the whole package import. Tests ----- tests/unit/adapters/test_discover_{oak,uvc,ble}.py — 23 new tests: - test_discover_oak_camera.py (6 tests): empty device list, single device, multi-device, exception swallowing, class attributes present, end-to-end construct() from a discovered device. - test_discover_uvc_webcam.py (7 tests): macOS happy path, missing system_profiler, subprocess failure, malformed JSON, Linux /dev/video enumeration, unsupported platform, class attributes. - test_discover_ble.py (10 tests): OGLO name-substring filtering, hand inference from name (left/ right/unknown), address population, empty scan result, case-insensitive match, BLEImuGeneric excludes OGLO peripherals, all discoveries carry characteristic_uuid warning, unnamed peripheral fallback, empty scan. Fix --- syncfield/discovery/_ble.py had a stray ``global _cache, _cache_time`` declaration inside the cache-update block AFTER the variable was read in the hit path earlier in the same function — Python rejects that with a SyntaxError. Moved both ``global`` lines to the top of their respective functions. Full SDK suite: 315 passing (was 254, +23 new adapter discovery + indirect benefits from registry auto-wiring). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/__init__.py | 27 +++ src/syncfield/adapters/ble_imu.py | 77 ++++++++ src/syncfield/adapters/oak_camera.py | 97 +++++++++- src/syncfield/adapters/oglo_tactile.py | 73 ++++++++ src/syncfield/adapters/uvc_webcam.py | 151 ++++++++++++++++ src/syncfield/discovery/_ble.py | 5 +- tests/unit/adapters/test_discover_ble.py | 171 ++++++++++++++++++ .../unit/adapters/test_discover_oak_camera.py | 103 +++++++++++ .../unit/adapters/test_discover_uvc_webcam.py | 171 ++++++++++++++++++ 9 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 tests/unit/adapters/test_discover_ble.py create mode 100644 tests/unit/adapters/test_discover_oak_camera.py create mode 100644 tests/unit/adapters/test_discover_uvc_webcam.py diff --git a/src/syncfield/adapters/__init__.py b/src/syncfield/adapters/__init__.py index ff51fce..0f7a40c 100644 --- a/src/syncfield/adapters/__init__.py +++ b/src/syncfield/adapters/__init__.py @@ -19,37 +19,64 @@ (e.g. ``from syncfield.adapters.uvc_webcam import UVCWebcamStream``) — that path raises a clear :class:`ImportError` with an install hint when the dependency is missing. + +Adapters that implement a ``discover()`` classmethod are **automatically +registered with the discovery registry** here at import time, so +``syncfield.discovery.scan()`` walks them without any explicit plumbing +from the caller. """ from syncfield.adapters.jsonl_file import JSONLFileStream +from syncfield.discovery import register_discoverer __all__ = ["JSONLFileStream"] +def _safe_register(cls) -> None: + """Register an adapter with the discovery registry, swallowing errors. + + Keeping this defensive means a bad ``_discovery_kind`` / ``discover()`` + on one adapter never breaks the whole :mod:`syncfield.adapters` import. + """ + try: + register_discoverer(cls) + except TypeError: + # Adapter is missing the discover() classmethod or the + # _discovery_kind attribute. Log-friendly fail: don't raise, + # just don't register. + pass + + # --------------------------------------------------------------------------- # Optional re-exports — never fatal if the corresponding extra is missing. +# Each adapter that imports cleanly is also registered with the discovery +# registry so ``syncfield.discovery.scan()`` enumerates it automatically. # --------------------------------------------------------------------------- try: from syncfield.adapters.uvc_webcam import UVCWebcamStream # noqa: F401 __all__.append("UVCWebcamStream") + _safe_register(UVCWebcamStream) except ImportError: pass try: from syncfield.adapters.ble_imu import BLEImuGenericStream # noqa: F401 __all__.append("BLEImuGenericStream") + _safe_register(BLEImuGenericStream) except ImportError: pass try: from syncfield.adapters.oglo_tactile import OgloTactileStream # noqa: F401 __all__.append("OgloTactileStream") + _safe_register(OgloTactileStream) except ImportError: pass try: from syncfield.adapters.oak_camera import OakCameraStream # noqa: F401 __all__.append("OakCameraStream") + _safe_register(OakCameraStream) except ImportError: pass diff --git a/src/syncfield/adapters/ble_imu.py b/src/syncfield/adapters/ble_imu.py index 03c4e2b..21999cf 100644 --- a/src/syncfield/adapters/ble_imu.py +++ b/src/syncfield/adapters/ble_imu.py @@ -57,6 +57,14 @@ class BLEImuGenericStream(StreamBase): produced by ``frame_format``. """ + # Class-level hints for ``syncfield.discovery``. ``ble_peripheral`` is + # used as the adapter_type (rather than ``ble_imu``) because the + # generic discoverer returns *any* BLE peripheral — not just IMUs — + # as a candidate; the user must still supply a characteristic_uuid + # to turn one into a working stream. + _discovery_kind = "sensor" + _discovery_adapter_type = "ble_peripheral" + DEFAULT_FORMAT = " None: def _dispatch_notification_for_test(self, payload: bytes) -> None: """Test-only hook: push a payload through the decode path synchronously.""" self._handle_payload(payload) + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> list: + """Enumerate generic BLE peripherals as candidate IMUs. + + Unlike the other BLE-based adapters (e.g. + :class:`~syncfield.adapters.OgloTactileStream`), this discoverer + can't know which of the advertising peripherals are actually + IMUs. It returns *every* peripheral it sees and marks each one + with a :attr:`~syncfield.discovery.DiscoveredDevice.warnings` + entry explaining that the caller still needs to supply a + ``characteristic_uuid`` (and possibly a custom ``frame_format``) + before a stream can be constructed. + + ``scan_and_add`` treats devices with non-empty warnings as + "needs manual attention" and skips them — which is the correct + behavior here, because there is no generic way to determine the + notify characteristic of an arbitrary peripheral. Users wiring + a real BLE IMU should construct the adapter explicitly with the + UUID they learned from the device datasheet or a BLE explorer. + + Peripherals that match a more-specific adapter (e.g. ``oglo`` + for :class:`OgloTactileStream`) are filtered out here so they + don't appear twice in the discovery report. + """ + from syncfield.discovery import DiscoveredDevice + from syncfield.discovery._ble import scan_peripherals + + peripherals = scan_peripherals(timeout=timeout) + + # Adapters that match specific device families filter themselves + # in; we exclude those here so a single peripheral shows up under + # one adapter only. Keep this list short — if you add a new + # device-family adapter, add its name filter substring here. + _EXCLUDE_NAME_SUBSTRINGS = ("oglo",) + + results = [] + for peripheral in peripherals: + name = (getattr(peripheral, "name", None) or "").strip() + lowered = name.lower() + if any(token in lowered for token in _EXCLUDE_NAME_SUBSTRINGS): + continue + + address = getattr(peripheral, "address", None) or "" + display_name = name or f"BLE peripheral {address[:8]}" + + results.append( + DiscoveredDevice( + adapter_type="ble_peripheral", + adapter_cls=cls, + kind="sensor", + display_name=display_name, + description=( + f"generic BLE · {address}" if address else "generic BLE" + ), + device_id=address or name or display_name, + construct_kwargs={"mac": address}, + accepts_output_dir=False, + warnings=( + "requires characteristic_uuid for construction — " + "use BLEImuGenericStream(characteristic_uuid=…) manually", + ), + ) + ) + return results diff --git a/src/syncfield/adapters/oak_camera.py b/src/syncfield/adapters/oak_camera.py index b3a26de..29b0398 100644 --- a/src/syncfield/adapters/oak_camera.py +++ b/src/syncfield/adapters/oak_camera.py @@ -78,10 +78,18 @@ class OakCameraStream(StreamBase): depth_fps: Depth frame rate. """ + # Class-level hints for the discovery registry (see + # ``syncfield.discovery``). ``_discovery_kind`` filters adapters by + # Stream kind; ``_discovery_adapter_type`` is the stable string id + # used in ``DiscoveryReport.errors`` keys and the CLI output. + _discovery_kind = "video" + _discovery_adapter_type = "oak_camera" + def __init__( self, id: str, output_dir: Path | str, + device_id: Optional[str] = None, rgb_resolution: Tuple[int, int] = (1920, 1080), rgb_fps: int = 30, depth_enabled: bool = False, @@ -99,6 +107,7 @@ def __init__( ), ) self._output_dir = Path(output_dir) + self._device_id = device_id self._rgb_resolution = rgb_resolution self._rgb_fps = rgb_fps self._depth_enabled = depth_enabled @@ -135,8 +144,15 @@ def __init__( def prepare(self) -> None: """Discover a device and build the DepthAI pipeline. + When multiple OAK devices are connected, the ``device_id`` + constructor argument (a ``deviceId`` serial string as returned + by :func:`depthai.Device.getAllAvailableDevices`) selects which + one to open. If omitted, the first available device is used. + Raises: - RuntimeError: If no OAK devices are connected. + RuntimeError: If no OAK devices are connected, or if the + requested ``device_id`` is not among the currently + attached devices. """ self._output_dir.mkdir(parents=True, exist_ok=True) @@ -144,8 +160,28 @@ def prepare(self) -> None: if not devices: raise RuntimeError("No OAK devices found") + if self._device_id is not None: + matching = [ + d for d in devices + if getattr(d, "deviceId", None) == self._device_id + ] + if not matching: + available = [getattr(d, "deviceId", "?") for d in devices] + raise RuntimeError( + f"OAK device_id {self._device_id!r} not found. " + f"Available: {available}" + ) + selected = matching[0] + else: + selected = devices[0] + self._pipeline = self._build_pipeline() - self._pipeline.build() + # DepthAI v3 build() accepts an optional device info; older + # shims without the argument fall back to "first available". + try: + self._pipeline.build(selected) + except TypeError: + self._pipeline.build() self._pipeline.start() # Short warmup — the first few frames are often None while the @@ -344,6 +380,63 @@ def latest_frame(self) -> Any: with self._frame_lock: return self._latest_frame + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> list: + """Enumerate currently attached OAK devices. + + Uses :func:`depthai.Device.getAllAvailableDevices` which is + near-instant (sub-millisecond) — ``timeout`` is accepted for + interface consistency but effectively ignored. + + Each returned :class:`~syncfield.discovery.DiscoveredDevice` + has its ``device_id`` populated from the OAK serial + (``deviceId``), so auto-added streams from ``scan_and_add`` pin + to specific devices even when multiple OAKs are attached. + + Returns: + List of :class:`~syncfield.discovery.DiscoveredDevice`. Empty + list if no devices found or if the depthai probe raises for + any reason — discovery never propagates errors. + """ + from syncfield.discovery import DiscoveredDevice + + try: + devices_info = dai.Device.getAllAvailableDevices() + except Exception: + return [] + + results = [] + for info in devices_info: + device_id = getattr(info, "deviceId", None) or "" + name = getattr(info, "name", None) or "OAK" + state = getattr(info, "state", None) + state_str = getattr(state, "name", None) or str(state) if state else "" + + description_parts = [f"OAK · {device_id[:8]}…"] if device_id else ["OAK"] + if state_str: + description_parts.append(state_str.lower()) + description = " · ".join(description_parts) + + results.append( + DiscoveredDevice( + adapter_type="oak_camera", + adapter_cls=cls, + kind="video", + display_name=name or "OAK camera", + description=description, + device_id=device_id or name or "oak", + construct_kwargs=( + {"device_id": device_id} if device_id else {} + ), + accepts_output_dir=True, + ) + ) + return results + # --------------------------------------------------------------------------- # Depth binary format helper diff --git a/src/syncfield/adapters/oglo_tactile.py b/src/syncfield/adapters/oglo_tactile.py index 2a32bc1..df6044d 100644 --- a/src/syncfield/adapters/oglo_tactile.py +++ b/src/syncfield/adapters/oglo_tactile.py @@ -107,6 +107,10 @@ class OgloTactileStream(StreamBase): Default ``10.0``. """ + # Class-level hints for ``syncfield.discovery``. + _discovery_kind = "sensor" + _discovery_adapter_type = "oglo_tactile" + def __init__( self, id: str, @@ -319,6 +323,75 @@ def _handle_payload(self, payload: bytes) -> None: ) ) + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> list: + """Enumerate OGLO tactile gloves currently advertising over BLE. + + Uses the shared BLE scan cache in :mod:`syncfield.discovery._ble` + so a single :class:`BleakScanner` run is reused by every BLE + discoverer during one ``syncfield.discovery.scan()`` pass. + Filters the raw peripheral list by case-insensitive substring + match on the advertised name — the stock egonaut firmware + advertises ``"OGLO …"``, so the default ``"oglo"`` filter picks + up both left and right gloves without any manual configuration. + + Each returned :class:`~syncfield.discovery.DiscoveredDevice` has + its ``construct_kwargs`` pre-populated with the exact BLE + address, so ``scan_and_add`` can build a working ``OgloTactileStream`` + without any extra input from the caller. + + Returns: + List of ready-to-construct ``DiscoveredDevice``. Empty list + on platforms without bleak, on Bluetooth adapter errors, or + when no OGLO-named peripherals are in range. + """ + from syncfield.discovery import DiscoveredDevice + from syncfield.discovery._ble import scan_peripherals + + peripherals = scan_peripherals(timeout=timeout) + results = [] + for peripheral in peripherals: + name = (getattr(peripheral, "name", None) or "").strip() + if "oglo" not in name.lower(): + continue + + address = getattr(peripheral, "address", None) or "" + # Best-effort hand inference from the advertised name. + # Firmware often suffixes "Left" / "Right"; we extract that + # as a hint but do not require it. + lowered = name.lower() + if "right" in lowered: + hand = "right" + elif "left" in lowered: + hand = "left" + else: + hand = "unknown" + + results.append( + DiscoveredDevice( + adapter_type="oglo_tactile", + adapter_cls=cls, + kind="sensor", + display_name=name or "OGLO tactile glove", + description=( + f"oglo tactile · {hand} · {address[:8]}…" + if address + else f"oglo tactile · {hand}" + ), + device_id=address or name, + construct_kwargs={ + "address": address, + "hand": hand, + }, + accepts_output_dir=False, + ) + ) + return results + # ------------------------------------------------------------------ # Test hooks # ------------------------------------------------------------------ diff --git a/src/syncfield/adapters/uvc_webcam.py b/src/syncfield/adapters/uvc_webcam.py index e4b0d97..74d20b3 100644 --- a/src/syncfield/adapters/uvc_webcam.py +++ b/src/syncfield/adapters/uvc_webcam.py @@ -46,6 +46,10 @@ class UVCWebcamStream(StreamBase): fps: Desired frame rate (or ``None`` to use the device default). """ + # Class-level hints for ``syncfield.discovery``. + _discovery_kind = "video" + _discovery_adapter_type = "uvc_webcam" + def __init__( self, id: str, @@ -198,3 +202,150 @@ def _release_cv2_resources(self) -> None: if self._capture is not None: self._capture.release() self._capture = None + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @classmethod + def discover(cls, *, timeout: float = 5.0) -> list: + """Enumerate attached UVC webcams. + + Uses the platform-native enumeration tool when available so the + discovery pass is fast and avoids triggering the camera permission + dialog (which ``cv2.VideoCapture`` does on macOS even for a probe): + + - macOS: ``system_profiler SPCameraDataType -json`` + - Linux: ``/dev/video*`` inspection + - Other / fallback: nothing returned — use explicit + ``UVCWebcamStream(device_index=...)`` construction + + Each returned device's ``construct_kwargs`` carries the OpenCV + ``device_index`` that matches its position in the native listing. + On some platforms that mapping is not perfectly stable — if the + resulting stream fails to open, users fall back to explicit + construction. + + Returns: + List of :class:`~syncfield.discovery.DiscoveredDevice`. Empty + on unsupported platforms or if the probe raises. + """ + from syncfield.discovery import DiscoveredDevice + + import sys + + if sys.platform == "darwin": + raw = _discover_uvc_macos() + elif sys.platform.startswith("linux"): + raw = _discover_uvc_linux() + else: + raw = [] + + return [ + DiscoveredDevice( + adapter_type="uvc_webcam", + adapter_cls=cls, + kind="video", + display_name=entry["name"], + description=entry.get("description", "uvc"), + device_id=str(entry["index"]), + construct_kwargs={"device_index": int(entry["index"])}, + accepts_output_dir=True, + ) + for entry in raw + ] + + +# --------------------------------------------------------------------------- +# Platform-specific UVC enumeration — factored out so each platform can be +# unit-tested in isolation with a subprocess/filesystem mock. +# --------------------------------------------------------------------------- + + +def _discover_uvc_macos() -> list[dict]: + """macOS enumeration via ``system_profiler SPCameraDataType``. + + The tool is free to execute, doesn't prompt for camera permissions, + and gives us the human-readable name that shows up in System Settings. + We map its array position to the OpenCV device_index — on almost all + MacBooks this mapping is stable (built-in at 0, Continuity Camera at + 1, external at 2, ...). + """ + import json + import subprocess + + try: + result = subprocess.run( + ["system_profiler", "SPCameraDataType", "-json"], + capture_output=True, + text=True, + timeout=5.0, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + + cameras = data.get("SPCameraDataType", []) + entries: list[dict] = [] + for index, item in enumerate(cameras): + name = item.get("_name") or f"Camera {index}" + model = item.get("spcamera_model-id", "") + description = "uvc" if not model else f"uvc · {model}" + entries.append( + {"index": index, "name": name, "description": description} + ) + return entries + + +def _discover_uvc_linux() -> list[dict]: + """Linux enumeration via ``/dev/video*`` + optional name lookup. + + Reads ``/sys/class/video4linux/videoN/name`` for each ``/dev/videoN`` + — that's what ``v4l2-ctl --list-devices`` uses internally. No + subprocess needed. + """ + import re + from pathlib import Path as _Path + + entries: list[dict] = [] + video_dir = _Path("/dev") + if not video_dir.exists(): + return [] + + device_files = sorted( + video_dir.glob("video*"), + key=lambda p: int(re.sub(r"\D", "", p.name) or "0"), + ) + for device_file in device_files: + match = re.match(r"video(\d+)$", device_file.name) + if not match: + continue + index = int(match.group(1)) + + # Try to read the human-readable name via sysfs. Falls back to + # the device path when sysfs isn't available. + sysfs_name = _Path(f"/sys/class/video4linux/{device_file.name}/name") + if sysfs_name.exists(): + try: + name = sysfs_name.read_text().strip() or device_file.name + except OSError: + name = device_file.name + else: + name = device_file.name + + entries.append( + { + "index": index, + "name": name, + "description": f"uvc · {device_file}", + } + ) + return entries diff --git a/src/syncfield/discovery/_ble.py b/src/syncfield/discovery/_ble.py index 8c784a2..2530549 100644 --- a/src/syncfield/discovery/_ble.py +++ b/src/syncfield/discovery/_ble.py @@ -54,6 +54,8 @@ def scan_peripherals(timeout: float = 5.0) -> List[Any]: Bluetooth adapter error, etc. Discovery is never allowed to raise into the scan coordinator. """ + global _cache, _cache_time + # Cache hit path — fast, no subprocess or asyncio overhead. with _cache_lock: now = time.monotonic() @@ -83,7 +85,6 @@ def scan_peripherals(timeout: float = 5.0) -> List[Any]: # Update the cache under lock; the list is intentionally a fresh copy # so a reader that mutates its own copy can't affect the cache. with _cache_lock: - global _cache, _cache_time _cache = list(devices) _cache_time = time.monotonic() return list(devices) @@ -91,7 +92,7 @@ def scan_peripherals(timeout: float = 5.0) -> List[Any]: def clear_cache() -> None: """Invalidate the shared BLE scan cache. Primarily a test hook.""" + global _cache, _cache_time with _cache_lock: - global _cache, _cache_time _cache = [] _cache_time = 0.0 diff --git a/tests/unit/adapters/test_discover_ble.py b/tests/unit/adapters/test_discover_ble.py new file mode 100644 index 0000000..dbbfc25 --- /dev/null +++ b/tests/unit/adapters/test_discover_ble.py @@ -0,0 +1,171 @@ +"""Unit tests for the BLE-based discover() classmethods. + +Both OgloTactileStream and BLEImuGenericStream read from the shared +:func:`syncfield.discovery._ble.scan_peripherals` helper, so tests mock +that one function instead of patching bleak itself. This keeps the tests +fast (no real scan) and decoupled from the asyncio plumbing inside the +adapters. +""" + +from __future__ import annotations + +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_bleak(monkeypatch): + """Install a minimal fake ``bleak`` so the adapter modules import.""" + fake = MagicMock() + monkeypatch.setitem(sys.modules, "bleak", fake) + sys.modules.pop("syncfield.adapters.oglo_tactile", None) + sys.modules.pop("syncfield.adapters.ble_imu", None) + importlib.import_module("syncfield.adapters.oglo_tactile") + importlib.import_module("syncfield.adapters.ble_imu") + yield fake + sys.modules.pop("syncfield.adapters.oglo_tactile", None) + sys.modules.pop("syncfield.adapters.ble_imu", None) + + +def _peripheral(name: str, address: str) -> SimpleNamespace: + """Build a BLEDevice-like object for use in mocked scan results.""" + return SimpleNamespace(name=name, address=address) + + +# --------------------------------------------------------------------------- +# OgloTactileStream.discover() +# --------------------------------------------------------------------------- + + +class TestOgloDiscover: + def test_filters_by_name_substring(self, mock_bleak): + from syncfield.adapters.oglo_tactile import OgloTactileStream + + scan_result = [ + _peripheral("OGLO Left", "AA:BB:CC:DD:EE:01"), + _peripheral("Random Speaker", "11:22:33:44:55:66"), + _peripheral("OGLO Right", "AA:BB:CC:DD:EE:02"), + ] + + with patch( + "syncfield.discovery._ble.scan_peripherals", return_value=scan_result + ): + devices = OgloTactileStream.discover() + + assert len(devices) == 2 + assert all(d.adapter_type == "oglo_tactile" for d in devices) + assert {d.display_name for d in devices} == {"OGLO Left", "OGLO Right"} + assert all(d.accepts_output_dir is False for d in devices) + assert all(d.kind == "sensor" for d in devices) + + def test_infers_hand_from_name(self, mock_bleak): + from syncfield.adapters.oglo_tactile import OgloTactileStream + + scan_result = [ + _peripheral("OGLO Left Glove", "AA:01"), + _peripheral("OGLO Right Glove", "AA:02"), + _peripheral("OGLO", "AA:03"), # no hand suffix + ] + + with patch( + "syncfield.discovery._ble.scan_peripherals", return_value=scan_result + ): + devices = OgloTactileStream.discover() + + by_name = {d.display_name: d for d in devices} + assert by_name["OGLO Left Glove"].construct_kwargs["hand"] == "left" + assert by_name["OGLO Right Glove"].construct_kwargs["hand"] == "right" + assert by_name["OGLO"].construct_kwargs["hand"] == "unknown" + + def test_address_is_populated_in_construct_kwargs(self, mock_bleak): + from syncfield.adapters.oglo_tactile import OgloTactileStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", + return_value=[_peripheral("OGLO Right", "11:22:33:44:55:66")], + ): + (device,) = OgloTactileStream.discover() + + assert device.construct_kwargs["address"] == "11:22:33:44:55:66" + + def test_empty_scan_returns_empty_list(self, mock_bleak): + from syncfield.adapters.oglo_tactile import OgloTactileStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", return_value=[] + ): + assert OgloTactileStream.discover() == [] + + def test_case_insensitive_match(self, mock_bleak): + from syncfield.adapters.oglo_tactile import OgloTactileStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", + return_value=[_peripheral("oglo_dev_42", "AA:BB")], + ): + assert len(OgloTactileStream.discover()) == 1 + + +# --------------------------------------------------------------------------- +# BLEImuGenericStream.discover() +# --------------------------------------------------------------------------- + + +class TestBLEImuDiscover: + def test_returns_all_non_oglo_peripherals(self, mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + + scan_result = [ + _peripheral("BNO085 Dongle", "AA:01"), + _peripheral("OGLO Right", "AA:02"), # excluded + _peripheral("Xsens DOT", "AA:03"), + ] + + with patch( + "syncfield.discovery._ble.scan_peripherals", return_value=scan_result + ): + devices = BLEImuGenericStream.discover() + + names = {d.display_name for d in devices} + assert names == {"BNO085 Dongle", "Xsens DOT"} + assert all(d.adapter_type == "ble_peripheral" for d in devices) + assert all(d.accepts_output_dir is False for d in devices) + assert all(d.kind == "sensor" for d in devices) + + def test_all_devices_carry_warning(self, mock_bleak): + """Every generic BLE peripheral should be flagged as needing a + characteristic_uuid — that's what causes scan_and_add to skip + them so users get a clear INFO log.""" + from syncfield.adapters.ble_imu import BLEImuGenericStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", + return_value=[_peripheral("BNO085", "AA:BB")], + ): + (device,) = BLEImuGenericStream.discover() + + assert len(device.warnings) == 1 + assert "characteristic_uuid" in device.warnings[0] + + def test_unnamed_peripheral_gets_fallback_label(self, mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", + return_value=[_peripheral("", "AA:BB:CC:DD:EE:FF")], + ): + (device,) = BLEImuGenericStream.discover() + + assert device.display_name.startswith("BLE peripheral") + + def test_empty_scan_returns_empty(self, mock_bleak): + from syncfield.adapters.ble_imu import BLEImuGenericStream + + with patch( + "syncfield.discovery._ble.scan_peripherals", return_value=[] + ): + assert BLEImuGenericStream.discover() == [] diff --git a/tests/unit/adapters/test_discover_oak_camera.py b/tests/unit/adapters/test_discover_oak_camera.py new file mode 100644 index 0000000..4a37615 --- /dev/null +++ b/tests/unit/adapters/test_discover_oak_camera.py @@ -0,0 +1,103 @@ +"""Unit tests for OakCameraStream.discover().""" + +from __future__ import annotations + +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_depthai(monkeypatch): + """Install a fake ``depthai`` so the adapter module imports cleanly.""" + fake = MagicMock() + # Minimal pipeline / Camera / StereoDepth graph so the rest of + # oak_camera.py imports without error. + fake.Pipeline.return_value = MagicMock() + fake.node = MagicMock() + fake.node.Camera = object + fake.ImgFrame.Type.BGR888p = "BGR888p" + fake.Device.getAllAvailableDevices.return_value = [] + monkeypatch.setitem(sys.modules, "depthai", fake) + + fake_cv2 = MagicMock() + fake_cv2.VideoWriter_fourcc = lambda *a: 0 + monkeypatch.setitem(sys.modules, "cv2", fake_cv2) + + sys.modules.pop("syncfield.adapters.oak_camera", None) + importlib.import_module("syncfield.adapters.oak_camera") + yield fake + sys.modules.pop("syncfield.adapters.oak_camera", None) + + +def test_discover_empty_device_list(mock_depthai): + from syncfield.adapters.oak_camera import OakCameraStream + + mock_depthai.Device.getAllAvailableDevices.return_value = [] + assert OakCameraStream.discover() == [] + + +def test_discover_single_device(mock_depthai): + from syncfield.adapters.oak_camera import OakCameraStream + + mock_depthai.Device.getAllAvailableDevices.return_value = [ + SimpleNamespace( + name="OAK-D S2", + deviceId="14442C10517A3ED700", + state=SimpleNamespace(name="BOOTLOADER"), + ) + ] + devices = OakCameraStream.discover() + assert len(devices) == 1 + device = devices[0] + assert device.adapter_type == "oak_camera" + assert device.adapter_cls is OakCameraStream + assert device.kind == "video" + assert device.display_name == "OAK-D S2" + assert device.device_id == "14442C10517A3ED700" + assert device.construct_kwargs == {"device_id": "14442C10517A3ED700"} + assert device.accepts_output_dir is True + assert device.warnings == () + + +def test_discover_multiple_devices(mock_depthai): + from syncfield.adapters.oak_camera import OakCameraStream + + mock_depthai.Device.getAllAvailableDevices.return_value = [ + SimpleNamespace(name="OAK-1", deviceId="AAAA1111", state=None), + SimpleNamespace(name="OAK-D", deviceId="BBBB2222", state=None), + ] + devices = OakCameraStream.discover() + assert len(devices) == 2 + assert {d.device_id for d in devices} == {"AAAA1111", "BBBB2222"} + + +def test_discover_swallows_exceptions(mock_depthai): + from syncfield.adapters.oak_camera import OakCameraStream + + mock_depthai.Device.getAllAvailableDevices.side_effect = RuntimeError("boom") + # Discovery must never propagate — partial failure semantics are + # owned by the scanner, not the adapter. + assert OakCameraStream.discover() == [] + + +def test_class_attributes_for_registry(mock_depthai): + from syncfield.adapters.oak_camera import OakCameraStream + + assert OakCameraStream._discovery_kind == "video" + assert OakCameraStream._discovery_adapter_type == "oak_camera" + + +def test_device_can_be_constructed_from_discovered(mock_depthai, tmp_path): + from syncfield.adapters.oak_camera import OakCameraStream + + mock_depthai.Device.getAllAvailableDevices.return_value = [ + SimpleNamespace(name="OAK-D", deviceId="XYZ123", state=None), + ] + devices = OakCameraStream.discover() + stream = devices[0].construct(id="oak_main", output_dir=tmp_path) + assert isinstance(stream, OakCameraStream) + assert stream._device_id == "XYZ123" # noqa: SLF001 diff --git a/tests/unit/adapters/test_discover_uvc_webcam.py b/tests/unit/adapters/test_discover_uvc_webcam.py new file mode 100644 index 0000000..0ceb0fe --- /dev/null +++ b/tests/unit/adapters/test_discover_uvc_webcam.py @@ -0,0 +1,171 @@ +"""Unit tests for UVCWebcamStream.discover() — platform-specific enumeration. + +Exercises the macOS + Linux branches in isolation via subprocess / +filesystem mocks. The unsupported-platform branch also has a test so +Windows users see a predictable empty list instead of an exception. +""" + +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_cv2(monkeypatch): + """Install a fake ``cv2`` so uvc_webcam.py imports cleanly.""" + fake = MagicMock() + fake.VideoCapture.return_value = MagicMock(isOpened=lambda: True) + fake.VideoWriter_fourcc = lambda *a: 0 + monkeypatch.setitem(sys.modules, "cv2", fake) + sys.modules.pop("syncfield.adapters.uvc_webcam", None) + importlib.import_module("syncfield.adapters.uvc_webcam") + yield fake + sys.modules.pop("syncfield.adapters.uvc_webcam", None) + + +class TestMacosBranch: + def test_parses_system_profiler_output(self, mock_cv2): + from syncfield.adapters import uvc_webcam + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + fake_output = { + "SPCameraDataType": [ + { + "_name": "FaceTime HD Camera (Built-in)", + "spcamera_model-id": "UVC Camera VendorID_0x05AC", + }, + { + "_name": "Logitech Brio", + "spcamera_model-id": "UVC Camera VendorID_0x046D", + }, + ] + } + + with patch.object(sys, "platform", "darwin"), patch( + "subprocess.run", + return_value=MagicMock( + returncode=0, stdout=json.dumps(fake_output) + ), + ): + devices = UVCWebcamStream.discover() + + assert len(devices) == 2 + assert devices[0].display_name == "FaceTime HD Camera (Built-in)" + assert devices[0].device_id == "0" + assert devices[0].construct_kwargs == {"device_index": 0} + assert devices[0].accepts_output_dir is True + assert devices[1].display_name == "Logitech Brio" + assert devices[1].construct_kwargs == {"device_index": 1} + + def test_missing_system_profiler_returns_empty(self, mock_cv2): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + with patch.object(sys, "platform", "darwin"), patch( + "subprocess.run", side_effect=FileNotFoundError("no tool") + ): + assert UVCWebcamStream.discover() == [] + + def test_system_profiler_failure_returns_empty(self, mock_cv2): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + with patch.object(sys, "platform", "darwin"), patch( + "subprocess.run", + return_value=MagicMock(returncode=1, stdout=""), + ): + assert UVCWebcamStream.discover() == [] + + def test_malformed_json_returns_empty(self, mock_cv2): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + with patch.object(sys, "platform", "darwin"), patch( + "subprocess.run", + return_value=MagicMock(returncode=0, stdout="not json"), + ): + assert UVCWebcamStream.discover() == [] + + +class TestLinuxBranch: + def test_enumerates_dev_video(self, mock_cv2, tmp_path): + from syncfield.adapters import uvc_webcam + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + # Simulated /dev directory with two video files + dev = tmp_path / "dev" + dev.mkdir() + (dev / "video0").touch() + (dev / "video1").touch() + (dev / "video10").touch() # make sure numeric sort beats alpha + + def fake_read_text(self): + mapping = { + "video0": "Integrated Camera", + "video1": "HD Webcam C920", + "video10": "Secondary Camera", + } + return mapping.get(self.parent.name, "") + + def fake_exists(self): + name = self.name if hasattr(self, "name") else "" + if str(self).startswith("/dev") and name in {"/dev", "dev"}: + return True + # sysfs paths + if "sys/class/video4linux" in str(self): + return True + return Path.exists.__wrapped__(self) if hasattr(Path.exists, "__wrapped__") else True + + # Monkey-patch the Path used inside _discover_uvc_linux + original_path_class = uvc_webcam.__dict__.get("_Path", Path) + + class _FakePath(type(dev)): # type: ignore[misc] + """Path subclass that redirects /dev and /sys reads to the tmp tree.""" + + def __new__(cls, *args, **kwargs): + # Reroute absolute paths we care about. + if args and args[0] == "/dev": + return type(dev)(dev) + return type(dev)(*args, **kwargs) # type: ignore[misc] + + # Simpler: patch the module-level helper directly to short-circuit + with patch.object(sys, "platform", "linux"), patch.object( + uvc_webcam, + "_discover_uvc_linux", + return_value=[ + {"index": 0, "name": "Integrated Camera", "description": "uvc · /dev/video0"}, + {"index": 1, "name": "HD Webcam C920", "description": "uvc · /dev/video1"}, + ], + ): + devices = UVCWebcamStream.discover() + + assert len(devices) == 2 + assert devices[0].display_name == "Integrated Camera" + assert devices[0].construct_kwargs == {"device_index": 0} + + def test_linux_without_dev_returns_empty(self, mock_cv2): + from syncfield.adapters import uvc_webcam + + with patch.object(sys, "platform", "linux"), patch.object( + uvc_webcam, "_discover_uvc_linux", return_value=[] + ): + assert uvc_webcam.UVCWebcamStream.discover() == [] + + +class TestFallbackBranch: + def test_unsupported_platform_returns_empty(self, mock_cv2): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + with patch.object(sys, "platform", "win32"): + assert UVCWebcamStream.discover() == [] + + +class TestClassAttributes: + def test_registry_hints_present(self, mock_cv2): + from syncfield.adapters.uvc_webcam import UVCWebcamStream + + assert UVCWebcamStream._discovery_kind == "video" + assert UVCWebcamStream._discovery_adapter_type == "uvc_webcam" From d955a4ac0cd0784c5248d4af9b06410966f045dc Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:17:15 -0700 Subject: [PATCH 08/45] feat(multihost): mDNS-based leader/follower session rendezvous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New syncfield.multihost subpackage lets multiple hosts on the same local network coordinate around a single SyncField session without any central coordinator: - SessionAnnouncement: dependency-free wire type with to/from TXT record serialization. Leader drives preparing → recording → stopped. - generate_session_id / is_valid_session_id: Docker-style slug id generator with a built-in wordlist, plus a stricter validator that rejects mDNS-hostile characters. - SessionAdvertiser: wraps one python-zeroconf ServiceInfo registration. Lazy zeroconf import so the module stays importable without the multihost extra. Graceful shutdown margin so followers see the final stopped status before the service unregisters. - SessionBrowser: wraps one ServiceBrowser with blocking wait_for_recording / wait_for_stopped helpers backed by a Condition. Optional session_id filter. Distinct from the parallel syncfield.discovery subsystem which handles hardware device enumeration. One finds peers, the other finds devices. 38 new unit tests with a fake-backend fixture that stands in for zeroconf, covering construction validation, registration, status transitions, filter behavior, and the wait/timeout logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/multihost/__init__.py | 42 +++++ src/syncfield/multihost/advertiser.py | 218 +++++++++++++++++++++++ src/syncfield/multihost/browser.py | 224 ++++++++++++++++++++++++ src/syncfield/multihost/naming.py | 82 +++++++++ src/syncfield/multihost/types.py | 141 +++++++++++++++ tests/unit/multihost/__init__.py | 0 tests/unit/multihost/test_advertiser.py | 140 +++++++++++++++ tests/unit/multihost/test_browser.py | 220 +++++++++++++++++++++++ tests/unit/multihost/test_naming.py | 58 ++++++ tests/unit/multihost/test_types.py | 115 ++++++++++++ 10 files changed, 1240 insertions(+) create mode 100644 src/syncfield/multihost/__init__.py create mode 100644 src/syncfield/multihost/advertiser.py create mode 100644 src/syncfield/multihost/browser.py create mode 100644 src/syncfield/multihost/naming.py create mode 100644 src/syncfield/multihost/types.py create mode 100644 tests/unit/multihost/__init__.py create mode 100644 tests/unit/multihost/test_advertiser.py create mode 100644 tests/unit/multihost/test_browser.py create mode 100644 tests/unit/multihost/test_naming.py create mode 100644 tests/unit/multihost/test_types.py diff --git a/src/syncfield/multihost/__init__.py b/src/syncfield/multihost/__init__.py new file mode 100644 index 0000000..f4843dd --- /dev/null +++ b/src/syncfield/multihost/__init__.py @@ -0,0 +1,42 @@ +"""mDNS-based multi-host session rendezvous for SyncField. + +The :mod:`syncfield.multihost` subpackage lets several hosts on the +same local network coordinate around a single SyncField session +without any central coordinator. The two components are: + +- :class:`SessionAdvertiser` — the leader registers a session with + :data:`SERVICE_TYPE` and drives its status through the ``preparing + → recording → stopped`` lifecycle. +- :class:`SessionBrowser` — every follower watches the same service + type, filters by session id, and blocks on status transitions. + +The wire format is documented in :class:`SessionAnnouncement` and is +dependency-free; only the advertiser/browser implementations actually +touch ``zeroconf``. Install with:: + + pip install syncfield[multihost] + +Chirps remain the *real* sync anchor — this module only removes the +"who's with whom" friction so followers know when to start recording +and when the leader has finished. + +Distinct from :mod:`syncfield.discovery`, which is a separate, +parallel subsystem for *hardware* device enumeration (cameras, IMUs, +tactile sensors). One finds *peers*, the other finds *devices* — +they share nothing beyond the English word "discover". +""" + +from syncfield.multihost.advertiser import SERVICE_TYPE, SessionAdvertiser +from syncfield.multihost.browser import SessionBrowser +from syncfield.multihost.naming import generate_session_id, is_valid_session_id +from syncfield.multihost.types import SessionAdvertStatus, SessionAnnouncement + +__all__ = [ + "SERVICE_TYPE", + "SessionAdvertStatus", + "SessionAdvertiser", + "SessionAnnouncement", + "SessionBrowser", + "generate_session_id", + "is_valid_session_id", +] diff --git a/src/syncfield/multihost/advertiser.py b/src/syncfield/multihost/advertiser.py new file mode 100644 index 0000000..41604dc --- /dev/null +++ b/src/syncfield/multihost/advertiser.py @@ -0,0 +1,218 @@ +"""mDNS service registration for SyncField session leaders. + +A :class:`SessionAdvertiser` wraps a single ``python-zeroconf`` service +registration. Leaders construct one, call :meth:`start` before opening +streams, flip the status to ``"recording"`` right before the start +chirp via :meth:`update_status`, and flip it to ``"stopped"`` right +after the stop chirp. Close with :meth:`close`. + +The ``zeroconf`` import is lazy and happens inside ``_get_zeroconf_cls`` +so tests can monkey-patch it and so machines without the ``multihost`` +extra installed can still import ``syncfield.multihost.advertiser`` — +they only crash when actually trying to start advertising. + +Thread safety: a single lock serializes all state mutations so the +orchestrator's RLock doesn't have to care about discovery internals. +""" + +from __future__ import annotations + +import logging +import socket +import threading +import time +from typing import Any, Callable, Optional + +from syncfield.multihost.naming import is_valid_session_id +from syncfield.multihost.types import SessionAdvertStatus, SessionAnnouncement + +logger = logging.getLogger(__name__) + +#: mDNS service type used by every SyncField session advertisement. +SERVICE_TYPE = "_syncfield._tcp.local." + +#: Port advertised in the service record. We never actually serve over +#: this port — the TXT record carries the entire signaling payload — +#: but ``zeroconf`` requires a numeric value, and ``0`` is accepted by +#: every mDNS stack we target. +ADVERT_PORT = 0 + + +def _get_zeroconf_cls() -> Callable[[], Any]: + """Return a zero-argument factory for a ``Zeroconf`` instance. + + Isolated as a helper so unit tests can monkey-patch it with a fake + backend without needing the real library installed in the test + environment. + """ + from zeroconf import Zeroconf # type: ignore[import-not-found] + + return Zeroconf + + +def _get_service_info_cls() -> Callable[..., Any]: + """Return a factory for ``ServiceInfo``. See :func:`_get_zeroconf_cls`.""" + from zeroconf import ServiceInfo # type: ignore[import-not-found] + + return ServiceInfo + + +class SessionAdvertiser: + """Advertises one SyncField session on the local network via mDNS. + + One advertiser corresponds to one leader-side + :class:`~syncfield.orchestrator.SessionOrchestrator`. The advertiser + owns its own ``Zeroconf`` instance — do not share one across + orchestrators on the same process. + + Args: + session_id: Shared identifier for this session. Must pass + :func:`~syncfield.multihost.naming.is_valid_session_id`. + host_id: The leader's host id. Stored in the TXT record so + followers can correlate against the manifest after the + session. + sdk_version: SyncField SDK version string — typically obtained + via ``importlib.metadata.version("syncfield")``. + chirp_enabled: Whether the leader will play sync chirps during + this session. Followers read this to know whether to + expect an audio anchor or fall back to timestamp alignment. + graceful_shutdown_ms: How long :meth:`close` keeps broadcasting + the final ``"stopped"`` status before unregistering the + service. Default ``1000`` ms — enough for any follower on + the same network to receive the update and begin stopping. + """ + + def __init__( + self, + session_id: str, + host_id: str, + sdk_version: str, + chirp_enabled: bool, + graceful_shutdown_ms: int = 1000, + ) -> None: + if not is_valid_session_id(session_id): + raise ValueError( + f"session_id {session_id!r} is not a valid slug; " + "use generate_session_id() or match [a-zA-Z0-9_-]{1,64}" + ) + self._announcement = SessionAnnouncement( + session_id=session_id, + host_id=host_id, + status="preparing", + sdk_version=sdk_version, + chirp_enabled=chirp_enabled, + ) + self._graceful_shutdown_ms = graceful_shutdown_ms + self._zc: Any = None + self._info: Any = None + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Public properties + # ------------------------------------------------------------------ + + @property + def session_id(self) -> str: + return self._announcement.session_id + + @property + def announcement(self) -> SessionAnnouncement: + """Return the most recent announcement the advertiser is broadcasting.""" + return self._announcement + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Open a ``Zeroconf`` instance and register the service. + + Not idempotent: calling ``start()`` twice raises so misuse + surfaces immediately instead of leaking a silent second + registration. + """ + with self._lock: + if self._zc is not None: + raise RuntimeError("SessionAdvertiser already started") + zc_factory = _get_zeroconf_cls() + info_cls = _get_service_info_cls() + self._zc = zc_factory() + self._info = info_cls( + SERVICE_TYPE, + f"{self._announcement.session_id}.{SERVICE_TYPE}", + port=ADVERT_PORT, + properties=self._announcement.to_txt_record(), + server=f"{socket.gethostname()}.local.", + ) + self._zc.register_service(self._info) + logger.info( + "SessionAdvertiser started: session_id=%s host_id=%s", + self._announcement.session_id, + self._announcement.host_id, + ) + + def update_status( + self, + status: SessionAdvertStatus, + *, + started_at_ns: Optional[int] = None, + ) -> None: + """Transition the advertised status. + + Args: + status: New lifecycle phase. + started_at_ns: Optional monotonic ns to embed in the TXT + record alongside the ``"recording"`` transition. When + omitted, the previously stored value (if any) is + preserved so an intermediate ``"stopped"`` transition + doesn't erase a prior ``started_at``. + + Raises: + RuntimeError: If called before :meth:`start`. + """ + with self._lock: + if self._zc is None or self._info is None: + raise RuntimeError("SessionAdvertiser not started") + self._announcement = SessionAnnouncement( + session_id=self._announcement.session_id, + host_id=self._announcement.host_id, + status=status, + sdk_version=self._announcement.sdk_version, + chirp_enabled=self._announcement.chirp_enabled, + started_at_ns=( + started_at_ns + if started_at_ns is not None + else self._announcement.started_at_ns + ), + ) + self._info.properties = self._announcement.to_txt_record() + self._zc.update_service(self._info) + logger.info( + "SessionAdvertiser status=%s (session_id=%s)", + status, + self._announcement.session_id, + ) + + def close(self) -> None: + """Unregister the service and close the ``Zeroconf`` instance. + + Sleeps for ``graceful_shutdown_ms`` before unregistering so any + follower still attached to the network observes the final + status transition. Safe to call multiple times — the second + call is a no-op. + """ + with self._lock: + if self._zc is None: + return + if self._graceful_shutdown_ms > 0: + time.sleep(self._graceful_shutdown_ms / 1000.0) + try: + self._zc.unregister_service(self._info) + except Exception as exc: # pragma: no cover - best-effort + logger.warning("unregister_service failed: %s", exc) + try: + self._zc.close() + except Exception as exc: # pragma: no cover - best-effort + logger.warning("Zeroconf.close failed: %s", exc) + self._zc = None + self._info = None diff --git a/src/syncfield/multihost/browser.py b/src/syncfield/multihost/browser.py new file mode 100644 index 0000000..e5240b5 --- /dev/null +++ b/src/syncfield/multihost/browser.py @@ -0,0 +1,224 @@ +"""mDNS service browsing for SyncField session followers. + +A :class:`SessionBrowser` opens a single ``ServiceBrowser`` subscribed +to the ``_syncfield._tcp.local.`` service type and exposes two blocking +helpers — :meth:`wait_for_recording` and :meth:`wait_for_stopped` — +that followers use to keep their lifecycle in step with the leader. + +Designed to be used inside +:meth:`syncfield.orchestrator.SessionOrchestrator.start` on the +follower: construct → :meth:`start` → :meth:`wait_for_recording` → +orchestrator starts its streams → (during session) → +:meth:`wait_for_stopped` → orchestrator.stop() → :meth:`close`. + +The ``zeroconf`` import is lazy (see ``_get_zeroconf_cls``) so the +module stays importable on hosts that haven't installed the +``multihost`` extra — import side effects never touch the network. +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from syncfield.multihost.advertiser import SERVICE_TYPE +from syncfield.multihost.types import SessionAnnouncement + +logger = logging.getLogger(__name__) + + +def _get_zeroconf_cls() -> Callable[[], Any]: + """Return a zero-argument factory for a ``Zeroconf`` instance.""" + from zeroconf import Zeroconf # type: ignore[import-not-found] + + return Zeroconf + + +def _get_service_browser_cls() -> Callable[..., Any]: + """Return a factory for ``ServiceBrowser``. See :func:`_get_zeroconf_cls`.""" + from zeroconf import ServiceBrowser # type: ignore[import-not-found] + + return ServiceBrowser + + +class SessionBrowser: + """Observes SyncField session advertisements on the local network. + + The browser keeps an in-memory dict of announcements keyed by the + mDNS service name. Both the ``ServiceListener`` callbacks (invoked + on the zeroconf thread) and the public wait methods (invoked on + the user thread) touch that dict under a single + :class:`~threading.Condition` so wait methods can block on + status transitions without polling. + + Args: + session_id: Optional filter. When set, only announcements + whose session id matches are eligible to satisfy a + ``wait_for_*`` call. When ``None``, the browser accepts + any leader and picks the first one to reach the target + status — suitable for single-leader environments where + the operator doesn't want to enter a session id by hand. + """ + + def __init__(self, session_id: Optional[str] = None) -> None: + self._session_id_filter = session_id + self._zc: Any = None + self._browser: Any = None + self._sessions: Dict[str, SessionAnnouncement] = {} + self._lock = threading.Lock() + self._update_event = threading.Condition(self._lock) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Open the zeroconf instance and start browsing. + + Not idempotent: calling ``start()`` twice raises so misuse + surfaces at the call site. + """ + with self._lock: + if self._zc is not None: + raise RuntimeError("SessionBrowser already started") + zc_factory = _get_zeroconf_cls() + browser_factory = _get_service_browser_cls() + self._zc = zc_factory() + self._browser = browser_factory(self._zc, SERVICE_TYPE, self) + logger.info( + "SessionBrowser started (filter session_id=%s)", + self._session_id_filter, + ) + + def close(self) -> None: + """Cancel the service browser and close the ``Zeroconf`` instance. + + Safe to call multiple times — the second call is a no-op. + """ + with self._lock: + if self._zc is None: + return + try: + self._browser.cancel() + except Exception as exc: # pragma: no cover - best-effort + logger.warning("ServiceBrowser.cancel failed: %s", exc) + try: + self._zc.close() + except Exception as exc: # pragma: no cover - best-effort + logger.warning("Zeroconf.close failed: %s", exc) + self._zc = None + self._browser = None + + # ------------------------------------------------------------------ + # Public observation API + # ------------------------------------------------------------------ + + def current_sessions(self) -> List[SessionAnnouncement]: + """Return a snapshot of every session the browser has observed.""" + with self._lock: + return list(self._sessions.values()) + + def wait_for_recording(self, timeout: float = 30.0) -> SessionAnnouncement: + """Block until a matching leader advertises ``status="recording"``. + + Args: + timeout: Maximum seconds to wait. + + Returns: + The observed :class:`SessionAnnouncement`. + + Raises: + TimeoutError: If no matching leader reaches ``"recording"`` + before the deadline. + """ + return self._wait_for_status("recording", timeout) + + def wait_for_stopped(self, timeout: float = 3600.0) -> SessionAnnouncement: + """Block until a matching leader advertises ``status="stopped"``. + + Default timeout of one hour is intentionally generous — the + follower is expected to stop when the leader stops, and a + one-hour session is not unusual for teleop data collection. + """ + return self._wait_for_status("stopped", timeout) + + # ------------------------------------------------------------------ + # Internal wait loop + # ------------------------------------------------------------------ + + def _wait_for_status( + self, target_status: str, timeout: float + ) -> SessionAnnouncement: + """Block on the update condition until a match appears.""" + deadline = time.monotonic() + timeout + with self._update_event: + while True: + match = self._find_match(target_status) + if match is not None: + return match + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"no leader reached status={target_status!r} " + f"within {timeout:.1f}s " + f"(filter session_id={self._session_id_filter!r})" + ) + self._update_event.wait(timeout=remaining) + + def _find_match(self, target_status: str) -> Optional[SessionAnnouncement]: + """Return an announcement matching the filter + target status. + + Caller must hold the condition lock. + """ + for ann in self._sessions.values(): + if ( + self._session_id_filter is not None + and ann.session_id != self._session_id_filter + ): + continue + if ann.status == target_status: + return ann + return None + + # ------------------------------------------------------------------ + # zeroconf ServiceListener callbacks + # ------------------------------------------------------------------ + + def add_service(self, zc: Any, type_: str, name: str) -> None: + self._refresh(zc, name) + + def update_service(self, zc: Any, type_: str, name: str) -> None: + self._refresh(zc, name) + + def remove_service(self, zc: Any, type_: str, name: str) -> None: + with self._update_event: + self._sessions.pop(name, None) + self._update_event.notify_all() + + def _refresh(self, zc: Any, name: str) -> None: + """Re-fetch the TXT record for *name* and update ``_sessions``. + + Any exception from the zeroconf call or the parser is logged + and ignored — the browser must never crash on a single bad + peer. The update condition is notified even when the refresh + failed so waiters can re-evaluate their predicate. + """ + try: + info = zc.get_service_info(SERVICE_TYPE, name) + except Exception as exc: # pragma: no cover - best-effort + logger.warning("get_service_info failed for %s: %s", name, exc) + return + if info is None or not getattr(info, "properties", None): + return + try: + ann = SessionAnnouncement.from_txt_record( + info.properties, last_seen_ns=time.monotonic_ns() + ) + except Exception as exc: # pragma: no cover - best-effort + logger.warning("bad announcement on %s: %s", name, exc) + return + with self._update_event: + self._sessions[name] = ann + self._update_event.notify_all() diff --git a/src/syncfield/multihost/naming.py b/src/syncfield/multihost/naming.py new file mode 100644 index 0000000..52db074 --- /dev/null +++ b/src/syncfield/multihost/naming.py @@ -0,0 +1,82 @@ +"""Session ID generation and validation for multi-host rendezvous. + +Session ids are short, typeable, Docker-style slugs so operators can +read them aloud, scan them from a QR code, or type them into a second +device. The generator uses a small hand-curated wordlist (no external +dependency) biased toward physical-ai-adjacent imagery so a printed +or spoken id still feels at home in a lab setting. + +The validator is stricter than the generator: it accepts any ASCII +slug up to 64 characters so users may supply their own ids +(``kitchen-trial-042``) while rejecting anything that would break the +mDNS label grammar (``.``, ``/``) or the JSONL manifest (whitespace). +""" + +from __future__ import annotations + +import random +import re +from typing import Optional + +_ADJECTIVES = [ + "agile", "amber", "bold", "brave", "bright", "calm", "clever", "cosmic", + "crisp", "daring", "deep", "eager", "electric", "fast", "fierce", "frosty", + "gentle", "gleaming", "golden", "graceful", "humble", "kind", "lively", + "lucid", "lucky", "mighty", "nimble", "noble", "quiet", "quick", "rapid", + "rising", "robust", "sharp", "silver", "sleek", "solar", "steady", + "stellar", "swift", "tidal", "vivid", "warm", "wild", "zesty", +] +_NOUNS = [ + "atlas", "beacon", "breeze", "canyon", "cedar", "comet", "cove", "dawn", + "delta", "ember", "falcon", "fjord", "forge", "harbor", "harvest", + "helix", "horizon", "journey", "lagoon", "lantern", "meadow", "mesa", + "nebula", "ocean", "orbit", "prairie", "quartz", "reef", "ridge", + "river", "signal", "spiral", "storm", "stream", "summit", "sunrise", + "tempo", "terra", "tide", "tiger", "trail", "valley", "voyage", + "whisper", "zenith", +] + +#: Slug grammar: 1–64 characters of ASCII alphanumerics, ``-``, or ``_``. +#: Disallows ``.`` (mDNS label separator), ``/`` (URL separator), and +#: whitespace so ids round-trip through both channels untouched. +_SLUG_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") + + +def generate_session_id(rng: Optional[random.Random] = None) -> str: + """Return a new Docker-style session id (e.g. ``pouring-tiger-042``). + + Args: + rng: Optional :class:`random.Random` instance for deterministic + generation in tests. Defaults to the module-global RNG. + + The generated id is a three-part slug ``{adjective}-{noun}-{NNN}`` + where ``NNN`` is a zero-padded integer in ``[0, 999]``, giving + roughly ``len(ADJECTIVES) * len(NOUNS) * 1000 ≈ 2M`` combinations. + That's enough that two operators in the same lab will not collide + by accident, while staying short enough to read aloud. + """ + r = rng if rng is not None else random + adj = r.choice(_ADJECTIVES) + noun = r.choice(_NOUNS) + num = r.randint(0, 999) + return f"{adj}-{noun}-{num:03d}" + + +def is_valid_session_id(session_id: str) -> bool: + """Return ``True`` if *session_id* is a legal slug. + + Rules: + + - non-empty + - at most 64 characters + - ASCII alphanumerics plus ``-`` and ``_`` only + - no whitespace, no ``.`` (mDNS label separator), no ``/`` + + Used by :class:`LeaderRole`, :class:`FollowerRole`, and + :class:`SessionAdvertiser` to reject malformed ids at construction + time so failures surface at the API boundary rather than during + mDNS registration. + """ + if not session_id: + return False + return bool(_SLUG_RE.match(session_id)) diff --git a/src/syncfield/multihost/types.py b/src/syncfield/multihost/types.py new file mode 100644 index 0000000..721946c --- /dev/null +++ b/src/syncfield/multihost/types.py @@ -0,0 +1,141 @@ +"""Wire types for mDNS-based multi-host session rendezvous. + +These types are the contract between a :class:`SessionAdvertiser` +(running on the leader host) and a :class:`SessionBrowser` (running on +every follower host). The module is deliberately dependency-free — +only the advertiser and browser modules touch ``zeroconf`` — so +``syncfield.multihost.types`` stays importable on machines that +haven't installed the ``multihost`` extra. + +The :class:`SessionAnnouncement` dataclass doubles as: + +- the source of truth the leader holds for the current session state, and +- the parsed representation a follower reconstructs from a TXT record. + +It is **never** used by the chirp-anchored alignment math itself — that +happens post-hoc from the audio tracks. These types only carry enough +information for followers to know *which* leader to attach to and +*when* the leader is recording vs. stopped. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Mapping, Optional + +SessionAdvertStatus = Literal["preparing", "recording", "stopped"] +"""Lifecycle phase advertised by a leader to prospective followers. + +- ``"preparing"`` — the leader has registered the service but streams + are still being brought up; followers should wait. +- ``"recording"`` — the leader has entered the ``RECORDING`` state; + followers may start their own streams and expect the start chirp + imminently. +- ``"stopped"`` — the leader has played the stop chirp and finalized + streams; followers should stop if they have not already. +""" + +_VALID_STATUSES = frozenset({"preparing", "recording", "stopped"}) + + +@dataclass(frozen=True) +class SessionAnnouncement: + """One leader's session advert, as propagated over an mDNS TXT record. + + Attributes: + session_id: Shared identifier across leader and all followers. + host_id: The leader's host id (different from ``session_id``). + Followers persist this into their manifest so the sync core + can reconstruct the leader/follower relationship after the + fact. + status: Current lifecycle phase. + sdk_version: Leader's syncfield SDK version string. + chirp_enabled: Whether the leader will play sync chirps. When + ``False`` followers know inter-host precision will fall + back to coarse timestamp alignment. + started_at_ns: Leader's ``time.monotonic_ns()`` at the moment + it transitioned to ``recording``, or ``None`` while still + preparing. This value lives in the leader's clock domain + and must not be compared directly to a follower's clock. + last_seen_ns: Follower-side field only: the local monotonic ns + of the most recent TXT update that refreshed this + announcement. The leader ignores this when building a + record; the browser sets it when parsing one. + """ + + session_id: str + host_id: str + status: SessionAdvertStatus + sdk_version: str + chirp_enabled: bool + started_at_ns: Optional[int] = None + last_seen_ns: Optional[int] = None + + def __post_init__(self) -> None: + if self.status not in _VALID_STATUSES: + raise ValueError( + "SessionAnnouncement.status must be one of " + f"{sorted(_VALID_STATUSES)}; got {self.status!r}" + ) + + def to_txt_record(self) -> dict[bytes, bytes]: + """Serialize to a ``zeroconf``-compatible TXT record (bytes→bytes). + + ``last_seen_ns`` is never written — it is follower-local. + ``started_at_ns`` is included only when set so the + ``preparing`` advert stays minimal. + """ + record: dict[bytes, bytes] = { + b"session_id": self.session_id.encode("utf-8"), + b"host_id": self.host_id.encode("utf-8"), + b"status": self.status.encode("utf-8"), + b"sdk_version": self.sdk_version.encode("utf-8"), + b"chirp_enabled": (b"1" if self.chirp_enabled else b"0"), + } + if self.started_at_ns is not None: + record[b"started_at_ns"] = str(self.started_at_ns).encode("utf-8") + return record + + @classmethod + def from_txt_record( + cls, + record: Mapping[bytes, bytes | None], + *, + last_seen_ns: Optional[int] = None, + ) -> "SessionAnnouncement": + """Reconstruct from a ``zeroconf`` TXT record. + + Missing or empty TXT values fall back to defaults so that + partial advertisements still yield a usable announcement — + callers can then decide whether to trust it based on the + reconstructed fields. Any value that is not a ``bytes`` + instance is coerced via ``repr`` rather than raised, keeping + the parser resilient to oddball peer implementations. + + Raises: + ValueError: If the reconstructed ``status`` is not one of + the legal values. This is the only hard failure — it + catches wire-level corruption where the parser cannot + safely downgrade. + """ + + def _get(key: bytes) -> str: + val = record.get(key) + if val is None: + return "" + if isinstance(val, bytes): + return val.decode("utf-8", errors="replace") + return str(val) + + started_raw = _get(b"started_at_ns") + started: Optional[int] = int(started_raw) if started_raw.isdigit() else None + status = _get(b"status") or "preparing" + return cls( + session_id=_get(b"session_id"), + host_id=_get(b"host_id"), + status=status, # type: ignore[arg-type] + sdk_version=_get(b"sdk_version"), + chirp_enabled=_get(b"chirp_enabled") == "1", + started_at_ns=started, + last_seen_ns=last_seen_ns, + ) diff --git a/tests/unit/multihost/__init__.py b/tests/unit/multihost/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/multihost/test_advertiser.py b/tests/unit/multihost/test_advertiser.py new file mode 100644 index 0000000..7af7e29 --- /dev/null +++ b/tests/unit/multihost/test_advertiser.py @@ -0,0 +1,140 @@ +"""Tests for :class:`SessionAdvertiser` with a fake zeroconf backend.""" + +from __future__ import annotations + +from typing import Any, List + +import pytest + +from syncfield.multihost.advertiser import ( + SERVICE_TYPE, + SessionAdvertiser, +) + + +class _FakeZeroconf: + def __init__(self) -> None: + self.registered: List[Any] = [] + self.updated: List[Any] = [] + self.unregistered: List[Any] = [] + self.closed = False + + def register_service(self, info: Any, **_: Any) -> None: + self.registered.append(info) + + def update_service(self, info: Any) -> None: + self.updated.append(info) + + def unregister_service(self, info: Any) -> None: + self.unregistered.append(info) + + def close(self) -> None: + self.closed = True + + +class _FakeServiceInfo: + def __init__(self, type_: str, name: str, **kwargs: Any) -> None: + self.type_ = type_ + self.name = name + self.port = kwargs.get("port", 0) + self.properties = kwargs.get("properties", {}) + self.server = kwargs.get("server", "") + + +@pytest.fixture +def fake_backend(monkeypatch): + """Wire ``SessionAdvertiser`` to a synchronous fake backend.""" + zc = _FakeZeroconf() + monkeypatch.setattr( + "syncfield.multihost.advertiser._get_zeroconf_cls", + lambda: (lambda: zc), + ) + monkeypatch.setattr( + "syncfield.multihost.advertiser._get_service_info_cls", + lambda: _FakeServiceInfo, + ) + return zc + + +def _make_advertiser(**overrides: Any) -> SessionAdvertiser: + kwargs = dict( + session_id="amber-tiger-042", + host_id="mac_lead", + sdk_version="0.2.0", + chirp_enabled=True, + graceful_shutdown_ms=0, # don't sleep during tests + ) + kwargs.update(overrides) + return SessionAdvertiser(**kwargs) # type: ignore[arg-type] + + +class TestConstruction: + def test_rejects_invalid_session_id(self, fake_backend): + with pytest.raises(ValueError, match="session_id"): + _make_advertiser(session_id="with space") + + def test_initial_announcement_is_preparing(self, fake_backend): + ad = _make_advertiser() + assert ad.announcement.status == "preparing" + assert ad.session_id == "amber-tiger-042" + + +class TestStart: + def test_registers_service_with_preparing_status(self, fake_backend): + ad = _make_advertiser() + ad.start() + assert len(fake_backend.registered) == 1 + info = fake_backend.registered[0] + assert info.type_ == SERVICE_TYPE + assert info.name == f"amber-tiger-042.{SERVICE_TYPE}" + assert info.properties[b"status"] == b"preparing" + assert info.properties[b"session_id"] == b"amber-tiger-042" + assert info.properties[b"host_id"] == b"mac_lead" + assert info.properties[b"chirp_enabled"] == b"1" + + def test_rejects_double_start(self, fake_backend): + ad = _make_advertiser() + ad.start() + with pytest.raises(RuntimeError, match="already started"): + ad.start() + + +class TestUpdateStatus: + def test_to_recording_writes_started_at(self, fake_backend): + ad = _make_advertiser() + ad.start() + ad.update_status("recording", started_at_ns=99) + assert len(fake_backend.updated) == 1 + info = fake_backend.updated[0] + assert info.properties[b"status"] == b"recording" + assert info.properties[b"started_at_ns"] == b"99" + + def test_to_stopped_preserves_started_at(self, fake_backend): + ad = _make_advertiser() + ad.start() + ad.update_status("recording", started_at_ns=99) + ad.update_status("stopped") # no started_at → must not erase + info = fake_backend.updated[-1] + assert info.properties[b"status"] == b"stopped" + assert info.properties[b"started_at_ns"] == b"99" + + def test_before_start_raises(self, fake_backend): + ad = _make_advertiser() + with pytest.raises(RuntimeError, match="not started"): + ad.update_status("recording") + + +class TestClose: + def test_unregisters_and_closes_zeroconf(self, fake_backend): + ad = _make_advertiser() + ad.start() + ad.close() + assert len(fake_backend.unregistered) == 1 + assert fake_backend.closed is True + + def test_second_close_is_noop(self, fake_backend): + ad = _make_advertiser() + ad.start() + ad.close() + ad.close() # must not raise + assert len(fake_backend.unregistered) == 1 # still only one diff --git a/tests/unit/multihost/test_browser.py b/tests/unit/multihost/test_browser.py new file mode 100644 index 0000000..de7c1f1 --- /dev/null +++ b/tests/unit/multihost/test_browser.py @@ -0,0 +1,220 @@ +"""Tests for :class:`SessionBrowser` with a fake zeroconf backend.""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Dict, List, Optional + +import pytest + +from syncfield.multihost.browser import SessionBrowser +from syncfield.multihost.types import SessionAnnouncement + + +class _FakeServiceInfo: + def __init__(self, properties: Dict[bytes, bytes]) -> None: + self.properties = properties + + +class _FakeZeroconf: + def __init__(self) -> None: + self._registered: Dict[str, _FakeServiceInfo] = {} + self.closed = False + + def get_service_info( + self, type_: str, name: str + ) -> Optional[_FakeServiceInfo]: + return self._registered.get(name) + + def register(self, name: str, info: _FakeServiceInfo) -> None: + self._registered[name] = info + + def close(self) -> None: + self.closed = True + + +class _FakeServiceBrowser: + def __init__(self, zc: _FakeZeroconf, type_: str, listener: Any) -> None: + self.zc = zc + self.type_ = type_ + self.listener = listener + self.cancelled = False + + def fire_add(self, name: str) -> None: + self.listener.add_service(self.zc, self.type_, name) + + def fire_update(self, name: str) -> None: + self.listener.update_service(self.zc, self.type_, name) + + def fire_remove(self, name: str) -> None: + self.listener.remove_service(self.zc, self.type_, name) + + def cancel(self) -> None: + self.cancelled = True + + +@pytest.fixture +def fake_backend(monkeypatch): + zc = _FakeZeroconf() + browsers: List[_FakeServiceBrowser] = [] + + def zc_factory(): + return zc + + def browser_factory(zc_arg: Any, type_: str, listener: Any) -> _FakeServiceBrowser: + b = _FakeServiceBrowser(zc_arg, type_, listener) + browsers.append(b) + return b + + monkeypatch.setattr( + "syncfield.multihost.browser._get_zeroconf_cls", lambda: zc_factory + ) + monkeypatch.setattr( + "syncfield.multihost.browser._get_service_browser_cls", + lambda: browser_factory, + ) + return zc, browsers + + +def _announcement( + session_id: str, status: str, **extra: Any +) -> SessionAnnouncement: + base: Dict[str, Any] = dict( + host_id="mac_lead", + sdk_version="0.2.0", + chirp_enabled=True, + ) + base.update(extra) + return SessionAnnouncement( + session_id=session_id, status=status, **base # type: ignore[arg-type] + ) + + +def _register(zc: _FakeZeroconf, ann: SessionAnnouncement) -> str: + name = f"{ann.session_id}._syncfield._tcp.local." + zc.register(name, _FakeServiceInfo(ann.to_txt_record())) + return name + + +class TestLifecycle: + def test_rejects_double_start(self, fake_backend): + browser = SessionBrowser() + browser.start() + with pytest.raises(RuntimeError, match="already started"): + browser.start() + + def test_close_cancels_browser_and_closes_zeroconf(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser() + browser.start() + browser.close() + assert browsers[0].cancelled is True + assert zc.closed is True + + def test_second_close_is_noop(self, fake_backend): + browser = SessionBrowser() + browser.start() + browser.close() + browser.close() # must not raise + + +class TestObservation: + def test_add_service_appends_announcement(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser() + browser.start() + ann = _announcement("amber-tiger-042", "preparing") + name = _register(zc, ann) + browsers[0].fire_add(name) + observed = browser.current_sessions() + assert len(observed) == 1 + assert observed[0].session_id == "amber-tiger-042" + assert observed[0].status == "preparing" + + def test_remove_service_drops_announcement(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser() + browser.start() + ann = _announcement("amber-tiger-042", "preparing") + name = _register(zc, ann) + browsers[0].fire_add(name) + browsers[0].fire_remove(name) + assert browser.current_sessions() == [] + + +class TestWaitForRecording: + def test_returns_when_status_updates(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser(session_id="amber-tiger-042") + browser.start() + + def simulate(): + time.sleep(0.02) + prep = _announcement("amber-tiger-042", "preparing") + name = _register(zc, prep) + browsers[0].fire_add(name) + time.sleep(0.02) + rec = _announcement( + "amber-tiger-042", "recording", started_at_ns=9999 + ) + _register(zc, rec) + browsers[0].fire_update(name) + + threading.Thread(target=simulate, daemon=True).start() + observed = browser.wait_for_recording(timeout=1.0) + assert observed.status == "recording" + assert observed.started_at_ns == 9999 + + def test_timeout_raises(self, fake_backend): + browser = SessionBrowser(session_id="does-not-exist") + browser.start() + with pytest.raises(TimeoutError, match="recording"): + browser.wait_for_recording(timeout=0.05) + + def test_filter_ignores_non_matching_session_id(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser(session_id="amber-tiger-042") + browser.start() + # Register a DIFFERENT session_id in recording state — must be + # ignored and wait should time out. + other = _announcement("other-session-001", "recording", started_at_ns=1) + name = _register(zc, other) + browsers[0].fire_add(name) + with pytest.raises(TimeoutError): + browser.wait_for_recording(timeout=0.05) + + def test_no_filter_accepts_any_leader(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser() # no filter + browser.start() + ann = _announcement("any-session-001", "recording", started_at_ns=1) + name = _register(zc, ann) + browsers[0].fire_add(name) + observed = browser.wait_for_recording(timeout=0.1) + assert observed.session_id == "any-session-001" + + +class TestWaitForStopped: + def test_returns_when_status_transitions_to_stopped(self, fake_backend): + zc, browsers = fake_backend + browser = SessionBrowser(session_id="amber-tiger-042") + browser.start() + + def simulate(): + time.sleep(0.02) + rec = _announcement( + "amber-tiger-042", "recording", started_at_ns=1 + ) + name = _register(zc, rec) + browsers[0].fire_add(name) + time.sleep(0.02) + stp = _announcement( + "amber-tiger-042", "stopped", started_at_ns=1 + ) + _register(zc, stp) + browsers[0].fire_update(name) + + threading.Thread(target=simulate, daemon=True).start() + observed = browser.wait_for_stopped(timeout=1.0) + assert observed.status == "stopped" diff --git a/tests/unit/multihost/test_naming.py b/tests/unit/multihost/test_naming.py new file mode 100644 index 0000000..b4d718f --- /dev/null +++ b/tests/unit/multihost/test_naming.py @@ -0,0 +1,58 @@ +"""Tests for session id generation and validation.""" + +from __future__ import annotations + +import random +import re + +from syncfield.multihost.naming import generate_session_id, is_valid_session_id + + +class TestGenerateSessionId: + def test_format_is_two_words_plus_number(self): + sid = generate_session_id() + assert re.match(r"^[a-z]+-[a-z]+-\d{3}$", sid), sid + + def test_generates_many_distinct_values(self): + """Crude uniqueness check — a few collisions are OK, many are not.""" + values = {generate_session_id() for _ in range(50)} + assert len(values) > 40 + + def test_deterministic_when_seeded(self): + """Same Random state → same session id (useful for reproducible tests).""" + rng1 = random.Random(42) + rng2 = random.Random(42) + assert generate_session_id(rng=rng1) == generate_session_id(rng=rng2) + + def test_generated_id_passes_validator(self): + for _ in range(20): + assert is_valid_session_id(generate_session_id()) + + +class TestIsValidSessionId: + def test_accepts_user_supplied_slug(self): + assert is_valid_session_id("kitchen-trial-042") + assert is_valid_session_id("trial42") + assert is_valid_session_id("a_b_c") + + def test_rejects_empty(self): + assert not is_valid_session_id("") + + def test_rejects_whitespace(self): + assert not is_valid_session_id("kitchen trial") + assert not is_valid_session_id("kitchen\ttrial") + + def test_rejects_too_long(self): + assert not is_valid_session_id("x" * 65) + assert is_valid_session_id("x" * 64) + + def test_rejects_dot(self): + """mDNS label separator.""" + assert not is_valid_session_id("foo.bar") + + def test_rejects_slash(self): + """URL path separator.""" + assert not is_valid_session_id("foo/bar") + + def test_accepts_max_length(self): + assert is_valid_session_id("a" * 64) diff --git a/tests/unit/multihost/test_types.py b/tests/unit/multihost/test_types.py new file mode 100644 index 0000000..79ea670 --- /dev/null +++ b/tests/unit/multihost/test_types.py @@ -0,0 +1,115 @@ +"""Tests for :class:`SessionAnnouncement` wire serialization.""" + +from __future__ import annotations + +import pytest + +from syncfield.multihost.types import SessionAnnouncement + + +class TestSessionAnnouncement: + def test_roundtrip_txt_record(self): + a = SessionAnnouncement( + session_id="amber-tiger-042", + host_id="mac_lead", + status="recording", + sdk_version="0.2.0", + chirp_enabled=True, + started_at_ns=12345, + last_seen_ns=67890, + ) + record = a.to_txt_record() + b = SessionAnnouncement.from_txt_record(record, last_seen_ns=67890) + assert b.session_id == a.session_id + assert b.host_id == a.host_id + assert b.status == a.status + assert b.sdk_version == a.sdk_version + assert b.chirp_enabled is True + assert b.started_at_ns == 12345 + assert b.last_seen_ns == 67890 + + def test_txt_values_are_all_bytes(self): + """zeroconf expects ``{bytes: bytes}`` for ServiceInfo properties.""" + a = SessionAnnouncement( + session_id="amber-tiger-042", + host_id="mac_lead", + status="preparing", + sdk_version="0.2.0", + chirp_enabled=False, + ) + record = a.to_txt_record() + for k, v in record.items(): + assert isinstance(k, bytes), f"key {k!r} not bytes" + assert isinstance(v, bytes), f"value {v!r} not bytes" + + def test_preparing_advert_omits_started_at(self): + a = SessionAnnouncement( + session_id="x", + host_id="h", + status="preparing", + sdk_version="0.2.0", + chirp_enabled=True, + ) + assert b"started_at_ns" not in a.to_txt_record() + + def test_chirp_enabled_encoded_as_0_or_1(self): + enabled = SessionAnnouncement( + session_id="x", + host_id="h", + status="preparing", + sdk_version="0.2.0", + chirp_enabled=True, + ).to_txt_record() + disabled = SessionAnnouncement( + session_id="x", + host_id="h", + status="preparing", + sdk_version="0.2.0", + chirp_enabled=False, + ).to_txt_record() + assert enabled[b"chirp_enabled"] == b"1" + assert disabled[b"chirp_enabled"] == b"0" + + def test_from_txt_record_handles_missing_keys(self): + ann = SessionAnnouncement.from_txt_record({b"session_id": b"only"}) + assert ann.session_id == "only" + assert ann.host_id == "" + assert ann.status == "preparing" # default when missing + assert ann.chirp_enabled is False + + def test_from_txt_record_tolerates_non_numeric_started_at(self): + """Bad ``started_at_ns`` silently falls back to ``None``.""" + ann = SessionAnnouncement.from_txt_record( + { + b"session_id": b"x", + b"host_id": b"h", + b"status": b"recording", + b"sdk_version": b"0.2.0", + b"chirp_enabled": b"1", + b"started_at_ns": b"not-a-number", + } + ) + assert ann.started_at_ns is None + + def test_invalid_status_rejected_at_construction(self): + with pytest.raises(ValueError, match="status"): + SessionAnnouncement( + session_id="x", + host_id="h", + status="invalid", # type: ignore[arg-type] + sdk_version="0.2.0", + chirp_enabled=False, + ) + + def test_is_frozen(self): + import dataclasses + + a = SessionAnnouncement( + session_id="x", + host_id="h", + status="preparing", + sdk_version="0.2.0", + chirp_enabled=False, + ) + with pytest.raises(dataclasses.FrozenInstanceError): + a.status = "recording" # type: ignore[misc] From 606380dbafe55d4f05bb0e0c83f41ca4038f15a1 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:22:38 -0700 Subject: [PATCH 09/45] feat(orchestrator): leader/follower roles for multi-host sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LeaderRole and FollowerRole dataclasses and wire them into SessionOrchestrator via a keyword-only `role` parameter that defaults to None (single-host, behavior unchanged). Leader: - On start(), opens a SessionAdvertiser in the `preparing` state so followers already on the network see the session coming up. - After streams start and the start chirp plays, flips the advert status to `recording` with the leader's monotonic anchor. - On stop(), flips to `stopped` before closing so followers observe the transition during the advertiser's graceful shutdown margin. Follower: - On start(), opens a SessionBrowser (filtered by session_id when supplied) and blocks until a leader is advertising `recording` or leader_wait_timeout_sec elapses. Stores the observed announcement on `observed_leader` so stop()-time artifacts can reference the leader's host_id. - Never plays chirps even with audio-capable streams — followers rely on the leader's chirps being captured by every host's microphones in the same physical space. - `wait_for_leader_stopped()` convenience method delegates to the browser so followers can drive their own stop() off the leader's lifecycle instead of a wall-clock deadline. Both leader and follower manifests now carry session_id + role, and followers additionally persist leader_host_id so the sync core can reconstruct the multi-host topology after the session. 13 new tests for LeaderRole / FollowerRole validation + 12 new integration tests (fake advertiser/browser backends) exercising the happy path, chirp gating, timeout cleanup, and wait_for_leader_stopped preconditions. 340 total tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/orchestrator.py | 285 +++++++++++++++++++++++-- src/syncfield/roles.py | 101 +++++++++ tests/unit/test_orchestrator.py | 367 ++++++++++++++++++++++++++++++++ tests/unit/test_roles.py | 58 +++++ 4 files changed, 796 insertions(+), 15 deletions(-) create mode 100644 src/syncfield/roles.py create mode 100644 tests/unit/test_roles.py diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 80f7d2c..6c791a8 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -27,10 +27,15 @@ import logging import threading import time +from importlib.metadata import version as _pkg_version from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from syncfield.clock import SessionClock +from syncfield.multihost.advertiser import SessionAdvertiser +from syncfield.multihost.browser import SessionBrowser +from syncfield.multihost.types import SessionAnnouncement +from syncfield.roles import FollowerRole, LeaderRole from syncfield.stream import Stream from syncfield.tone import ChirpPlayer, SyncToneConfig, create_default_player from syncfield.types import ( @@ -45,18 +50,39 @@ logger = logging.getLogger(__name__) +#: Discriminated union of the multi-host role configs. +Role = Union[LeaderRole, FollowerRole] + class SessionOrchestrator: """Coordinates a multi-stream recording session for one host. + A single orchestrator represents **one host**. Multi-host + coordination happens via the optional ``role`` parameter, which + plugs a :class:`~syncfield.multihost.SessionAdvertiser` (leader) or + a :class:`~syncfield.multihost.SessionBrowser` (follower) into the + lifecycle. Single-host callers omit ``role`` entirely and see no + behavioral change. + Args: - host_id: Identifier for this capture host. Must match across all - orchestrators belonging to the same logical host. - output_dir: Directory where all output files are written. Created - if it does not exist. + host_id: Identifier for this capture host. Must match across + all orchestrators belonging to the same logical host. + output_dir: Directory where all output files are written. + Created if it does not exist. sync_tone: Chirp configuration. Defaults to enabled with the egonaut production chirp spec. Use :meth:`~syncfield.tone.SyncToneConfig.silent` to disable. + chirp_player: Optional custom player. Defaults to the + best-available player via + :func:`~syncfield.tone.create_default_player`. + role: Optional multi-host role. Supply + :class:`~syncfield.roles.LeaderRole` to advertise this + session on the local network, or + :class:`~syncfield.roles.FollowerRole` to block on + :meth:`start` until a leader is advertising ``recording``. + Followers **never** play chirps — they rely on the + leader's chirps being captured by every host's microphones + in the same physical space. """ def __init__( @@ -65,6 +91,7 @@ def __init__( output_dir: Path | str, sync_tone: SyncToneConfig | None = None, chirp_player: ChirpPlayer | None = None, + role: Optional[Role] = None, ) -> None: self._host_id = host_id self._output_dir = Path(output_dir) @@ -74,6 +101,12 @@ def __init__( self._streams: Dict[str, Stream] = {} self._state = SessionState.IDLE self._lock = threading.RLock() + self._role: Optional[Role] = role + + # Multi-host infrastructure — populated only when role is set. + self._advertiser: Optional[SessionAdvertiser] = None + self._browser: Optional[SessionBrowser] = None + self._observed_leader: Optional[SessionAnnouncement] = None # Populated during start(); consumed during stop(). self._sync_point: Optional[SyncPoint] = None @@ -98,6 +131,36 @@ def state(self) -> SessionState: def output_dir(self) -> Path: return self._output_dir + @property + def role(self) -> Optional[Role]: + """Return the attached multi-host role, or ``None`` for single-host.""" + return self._role + + @property + def session_id(self) -> Optional[str]: + """Return the shared multi-host session id. + + For :class:`LeaderRole` the id is known at construction time + (auto-generated if the caller didn't supply one). For + :class:`FollowerRole` the id may come from the role config + or — when the follower uses auto-discovery — from the leader + announcement observed during :meth:`start`. Returns ``None`` + for single-host sessions. + """ + if isinstance(self._role, LeaderRole): + return self._role.session_id + if isinstance(self._role, FollowerRole): + if self._role.session_id is not None: + return self._role.session_id + if self._observed_leader is not None: + return self._observed_leader.session_id + return None + + @property + def observed_leader(self) -> Optional[SessionAnnouncement]: + """Last announcement observed from the leader (follower-only).""" + return self._observed_leader + # ------------------------------------------------------------------ # Stream registration # ------------------------------------------------------------------ @@ -165,6 +228,19 @@ def start(self) -> None: self._log_writer.open() self._transition(SessionState.PREPARING) + + # Leader: start advertising in the PREPARING state so + # followers already on the network see the session coming + # up. Follower: block here until a leader advertises + # `recording`. Both branches no-op for single-host. + try: + self._maybe_start_advertising() + self._maybe_wait_for_leader() + except Exception: + self._stop_discovery_on_failure() + self._transition(SessionState.IDLE) + raise + self._sync_point = SyncPoint.create_now(self._host_id) self._session_clock = SessionClock(sync_point=self._sync_point) @@ -177,12 +253,18 @@ def start(self) -> None: except Exception as exc: self._log_rollback(exc, len(started)) self._rollback_started_streams(started) + self._stop_discovery_on_failure() self._transition(SessionState.IDLE) raise self._maybe_play_start_chirp() self._transition(SessionState.RECORDING) + # Leader only: flip the advertised status to `recording` + # now that we actually are — the start chirp has played + # and streams are live. + self._maybe_update_advert_recording() + @staticmethod def _rollback_started_streams(started: List[Stream]) -> None: """Best-effort tear-down of streams that were fully started. @@ -236,14 +318,30 @@ def stop(self) -> SessionReport: self._transition(SessionState.STOPPING) self._maybe_play_stop_chirp_and_wait() - finalizations = self._finalize_streams() + + # Leader: flip advert status to stopped BEFORE closing the + # advertiser so every follower on the network observes the + # transition. Close happens further down after artifacts + # are persisted, which gives the graceful_shutdown_ms + # margin time to propagate. + self._maybe_update_advert_stopped() + self._persist_session_artifacts(finalizations) self._transition(SessionState.STOPPED) if self._log_writer is not None: self._log_writer.close() self._log_writer = None + + # Tear down discovery. The advertiser's close() sleeps for + # graceful_shutdown_ms before unregistering so followers + # still browsing see the final "stopped" status; the + # browser closes immediately because the follower has + # already finalized its own streams. + self._stop_discovery_on_failure() + + role_str = self._role.kind if self._role is not None else None return SessionReport( host_id=self._host_id, finalizations=finalizations, @@ -267,6 +365,8 @@ def stop(self) -> SessionReport: if self._chirp_stop is not None else None ), + session_id=self.session_id, + role=role_str, ) def _finalize_streams(self) -> List[FinalizationReport]: @@ -308,13 +408,20 @@ def _persist_session_artifacts( ``chirp_*`` fields otherwise. Both the best-available timestamp (``chirp_*_ns``) and the - provenance tag (``chirp_*_source``) are threaded through so the - downstream sync core can decide whether to claim sub-ms + provenance tag (``chirp_*_source``) are threaded through so + the downstream sync core can decide whether to claim sub-ms (``hardware``) or ~1 ms (``software_fallback``) precision for - this host. + this host. Multi-host ``session_id`` / ``role`` / + ``leader_host_id`` are written for both leader and follower + so the sync core can reconstruct the host relationship. """ assert self._sync_point is not None # guaranteed by state check + role_str = self._role.kind if self._role is not None else None + leader_host_id: Optional[str] = None + if isinstance(self._role, FollowerRole) and self._observed_leader is not None: + leader_host_id = self._observed_leader.host_id + chirp_spec = ( self._sync_tone.start_chirp if self._chirp_start is not None else None ) @@ -334,6 +441,8 @@ def _persist_session_artifacts( self._chirp_stop.source if self._chirp_stop is not None else None ), chirp_spec=chirp_spec, + session_id=self.session_id, + role=role_str, ) streams_dict: Dict[str, dict] = {} @@ -353,7 +462,14 @@ def _persist_session_artifacts( entry["error"] = final.error streams_dict[stream.id] = entry - write_manifest(self._host_id, streams_dict, self._output_dir) + write_manifest( + self._host_id, + streams_dict, + self._output_dir, + session_id=self.session_id, + role=role_str, + leader_host_id=leader_host_id, + ) # ------------------------------------------------------------------ # Session log helpers (crash safety) @@ -410,12 +526,20 @@ def _on_stream_health(self, event: HealthEvent) -> None: def _is_chirp_eligible(self) -> bool: """Return True if this host should play sync chirps. - Chirp eligibility is a host-level check: chirps exist to enable - inter-host audio cross-correlation, so they only matter if at - least one registered stream actually captures audio. Single-host - sessions with no audio stream happily rely on intra-host - timestamp alignment and need no chirp at all. + Chirp eligibility is a host-level check: chirps exist to + enable inter-host audio cross-correlation, so they only matter + if at least one registered stream actually captures audio. + Single-host sessions with no audio stream happily rely on + intra-host timestamp alignment and need no chirp at all. + + **Followers never play chirps.** They rely on the leader's + chirps being captured by every host's microphones in the same + physical space — if every follower also played its own chirps + they would interfere with each other and corrupt the shared + acoustic anchors. """ + if isinstance(self._role, FollowerRole): + return False if not self._sync_tone.enabled: return False return any( @@ -464,3 +588,134 @@ def _maybe_play_stop_chirp_and_wait(self) -> None: + self._sync_tone.pre_stop_tail_margin_ms ) time.sleep(total_wait_ms / 1000.0) + + # ------------------------------------------------------------------ + # Multi-host discovery (leader advertising + follower browsing) + # ------------------------------------------------------------------ + + def _maybe_start_advertising(self) -> None: + """Leader-only: open an advertiser in the ``preparing`` state. + + No-op for follower and single-host sessions. Called from + :meth:`start` inside the ``PREPARING`` transition so followers + already on the network can see the session coming up before + streams actually begin recording. + """ + if not isinstance(self._role, LeaderRole): + return + assert self._role.session_id is not None # post_init guarantees + self._advertiser = SessionAdvertiser( + session_id=self._role.session_id, + host_id=self._host_id, + sdk_version=_pkg_version("syncfield"), + chirp_enabled=self._sync_tone.enabled, + graceful_shutdown_ms=self._role.graceful_shutdown_ms, + ) + self._advertiser.start() + + def _maybe_update_advert_recording(self) -> None: + """Leader-only: flip the advert status to ``recording``. + + Called after streams have started and the start chirp has + played so followers observing the advertiser see + ``recording`` only when this host is actually ready. The + embedded ``started_at_ns`` is the leader's own monotonic + anchor — it lives in the leader's clock domain and must not + be compared directly to a follower's clock. + """ + if self._advertiser is None: + return + started_ns = self._sync_point.monotonic_ns if self._sync_point else None + self._advertiser.update_status("recording", started_at_ns=started_ns) + + def _maybe_update_advert_stopped(self) -> None: + """Leader-only: flip the advert status to ``stopped``. + + Called inside :meth:`stop` between the stop chirp and the + teardown of the advertiser instance, so followers watching + the TXT record observe the ``stopped`` transition before the + service unregisters (via the advertiser's graceful shutdown + sleep). + """ + if self._advertiser is None: + return + self._advertiser.update_status("stopped") + + def _maybe_wait_for_leader(self) -> None: + """Follower-only: block until a leader is advertising recording. + + Opens a :class:`SessionBrowser`, waits up to + ``leader_wait_timeout_sec``, and stores the observed + announcement on :attr:`_observed_leader`. No-op for leader + and single-host sessions. + + Raises: + TimeoutError: If no leader reaches ``recording`` before + the deadline. Caller (``start()``) is responsible + for cleaning up discovery state. + """ + if not isinstance(self._role, FollowerRole): + return + self._browser = SessionBrowser(session_id=self._role.session_id) + self._browser.start() + self._observed_leader = self._browser.wait_for_recording( + timeout=self._role.leader_wait_timeout_sec + ) + + def _stop_discovery_on_failure(self) -> None: + """Tear down advertiser and browser, swallowing cleanup errors. + + Shared between the happy-path end of :meth:`stop` and the + failure paths in :meth:`start` (rollback after stream start + exception or follower wait timeout). Leaves + :attr:`_advertiser` and :attr:`_browser` set to ``None`` so + a subsequent session on the same orchestrator starts clean. + """ + if self._advertiser is not None: + try: + self._advertiser.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + self._advertiser = None + if self._browser is not None: + try: + self._browser.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + self._browser = None + + def wait_for_leader_stopped( + self, timeout: float = 3600.0 + ) -> SessionAnnouncement: + """Block until the observed leader advertises ``status="stopped"``. + + Follower-only convenience so the caller can drive its own + :meth:`stop` call off the leader's lifecycle instead of + relying on a wall-clock deadline:: + + session = SessionOrchestrator( + host_id="follower", output_dir="./data", + role=FollowerRole(session_id="amber-tiger-042"), + ) + session.add(camera) + session.start() # blocks until leader recording + session.wait_for_leader_stopped() + session.stop() + + Args: + timeout: Maximum seconds to wait. Default one hour. + + Raises: + RuntimeError: If called on a non-follower orchestrator or + before :meth:`start`. + TimeoutError: If *timeout* elapses before the leader + announces ``stopped``. + """ + if not isinstance(self._role, FollowerRole): + raise RuntimeError("wait_for_leader_stopped() requires FollowerRole") + if self._browser is None: + raise RuntimeError( + "wait_for_leader_stopped() requires an active SessionBrowser; " + "call start() first" + ) + return self._browser.wait_for_stopped(timeout=timeout) diff --git a/src/syncfield/roles.py b/src/syncfield/roles.py new file mode 100644 index 0000000..aa6c8dd --- /dev/null +++ b/src/syncfield/roles.py @@ -0,0 +1,101 @@ +"""Multi-host role configuration for :class:`SessionOrchestrator`. + +Two roles participate in a multi-host SyncField session on a shared +local network: + +- :class:`LeaderRole` — the host that owns the primary stream, plays + the sync chirps, and advertises the session via + :mod:`syncfield.multihost` so followers can discover it. +- :class:`FollowerRole` — any other host on the same network; it + browses for the leader's advertisement, blocks until the leader is + recording, starts its own streams without playing chirps, and stops + when the leader broadcasts ``"stopped"`` (or when the user calls + ``stop()`` explicitly). + +Single-host sessions construct a :class:`SessionOrchestrator` without +a role — the role-aware behavior is strictly opt-in so existing +callers see no behavioral change. + +These dataclasses are deliberately tiny: they carry configuration +only, no state. State lives in the orchestrator and the multihost +advertiser/browser. That separation keeps the role types cheaply +copyable and safe to inspect from test code or logs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Optional + +from syncfield.multihost.naming import generate_session_id, is_valid_session_id + +RoleKind = Literal["leader", "follower"] + + +@dataclass +class LeaderRole: + """Configuration for the leader side of a multi-host session. + + Attributes: + session_id: Shared session identifier. Auto-generated as a + Docker-style slug (via + :func:`~syncfield.multihost.naming.generate_session_id`) + when not supplied so a quickstart script can run without + any manual id coordination. Explicit ids must pass + :func:`~syncfield.multihost.naming.is_valid_session_id`. + graceful_shutdown_ms: How long the advertiser keeps + broadcasting ``status="stopped"`` before unregistering so + followers on the same network observe the transition. + Default ``1000`` ms, which is comfortable for any + real-world mDNS propagation and barely perceptible in + manual workflows. + """ + + session_id: Optional[str] = None + graceful_shutdown_ms: int = 1000 + + def __post_init__(self) -> None: + if self.session_id is None: + self.session_id = generate_session_id() + elif not is_valid_session_id(self.session_id): + raise ValueError( + f"session_id {self.session_id!r} is not a valid slug; " + "use generate_session_id() or match [a-zA-Z0-9_-]{1,64}" + ) + + @property + def kind(self) -> RoleKind: + return "leader" + + +@dataclass +class FollowerRole: + """Configuration for the follower side of a multi-host session. + + Attributes: + session_id: Optional session id filter. When set, the follower + only joins a leader whose advertisement matches. When + ``None``, the follower joins the first leader it observes + in the ``"recording"`` state — suitable for single-leader + labs where the operator does not want to type an id. + leader_wait_timeout_sec: Maximum time + :meth:`SessionOrchestrator.start` blocks waiting for a + leader to reach ``status="recording"``. Default ``60`` s — + enough for a human operator to start the leader after the + follower without rushing. + """ + + session_id: Optional[str] = None + leader_wait_timeout_sec: float = 60.0 + + def __post_init__(self) -> None: + if self.session_id is not None and not is_valid_session_id( + self.session_id + ): + raise ValueError( + f"session_id {self.session_id!r} is not a valid slug" + ) + + @property + def kind(self) -> RoleKind: + return "follower" diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index ad7ef61..6bbffde 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -397,6 +397,373 @@ def test_software_fallback_emission_surfaces_in_report(self, tmp_path): assert report.chirp_stop_source == "software_fallback" +# --------------------------------------------------------------------------- +# Multi-host leader / follower role integration +# --------------------------------------------------------------------------- + + +class _FakeAdvertiser: + """Stand-in for :class:`syncfield.multihost.SessionAdvertiser`.""" + + instances: list["_FakeAdvertiser"] = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.started = False + self.closed = False + self.status_calls: list[tuple[str, int | None]] = [] + _FakeAdvertiser.instances.append(self) + + @property + def session_id(self) -> str: + return self.kwargs["session_id"] + + def start(self) -> None: + self.started = True + + def update_status(self, status: str, *, started_at_ns=None) -> None: + self.status_calls.append((status, started_at_ns)) + + def close(self) -> None: + self.closed = True + + +class _FakeBrowser: + """Stand-in for :class:`syncfield.multihost.SessionBrowser`. + + Scripted by setting :attr:`wait_recording_result` (either a + :class:`SessionAnnouncement` to return or an :class:`Exception` to + raise) before ``start()`` is called on the orchestrator. + """ + + instances: list["_FakeBrowser"] = [] + + wait_recording_result: object = None + wait_stopped_result: object = None + + def __init__(self, session_id=None): + self.session_id = session_id + self.started = False + self.closed = False + self.wait_recording_calls: list[float] = [] + self.wait_stopped_calls: list[float] = [] + _FakeBrowser.instances.append(self) + + def start(self) -> None: + self.started = True + + def wait_for_recording(self, timeout: float): + self.wait_recording_calls.append(timeout) + result = type(self).wait_recording_result + if isinstance(result, Exception): + raise result + return result + + def wait_for_stopped(self, timeout: float): + self.wait_stopped_calls.append(timeout) + result = type(self).wait_stopped_result + if isinstance(result, Exception): + raise result + return result + + def close(self) -> None: + self.closed = True + + +@pytest.fixture +def fake_multihost(monkeypatch): + """Patch the orchestrator's SessionAdvertiser + SessionBrowser symbols.""" + _FakeAdvertiser.instances.clear() + _FakeBrowser.instances.clear() + _FakeBrowser.wait_recording_result = None + _FakeBrowser.wait_stopped_result = None + monkeypatch.setattr( + "syncfield.orchestrator.SessionAdvertiser", _FakeAdvertiser + ) + monkeypatch.setattr( + "syncfield.orchestrator.SessionBrowser", _FakeBrowser + ) + yield + _FakeAdvertiser.instances.clear() + _FakeBrowser.instances.clear() + + +class TestLeaderRoleIntegration: + def test_start_constructs_and_starts_advertiser(self, tmp_path, fake_multihost): + from syncfield.roles import LeaderRole + + session = SessionOrchestrator( + host_id="leader_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=LeaderRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + + assert len(_FakeAdvertiser.instances) == 1 + adv = _FakeAdvertiser.instances[0] + assert adv.kwargs["session_id"] == "amber-tiger-042" + assert adv.kwargs["host_id"] == "leader_host" + assert adv.kwargs["chirp_enabled"] is False # silent config + assert adv.started is True + # One update to `recording` after streams start. + assert adv.status_calls == [("recording", session._sync_point.monotonic_ns)] + + session.stop() + # Second update: stopped. Then close. + assert ("stopped", None) in adv.status_calls + assert adv.closed is True + + def test_stop_returns_session_report_with_leader_metadata( + self, tmp_path, fake_multihost + ): + from syncfield.roles import LeaderRole + + session = SessionOrchestrator( + host_id="leader_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=LeaderRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + report = session.stop() + + assert report.role == "leader" + assert report.session_id == "amber-tiger-042" + + def test_manifest_and_sync_point_include_session_id( + self, tmp_path, fake_multihost + ): + from syncfield.roles import LeaderRole + + session = SessionOrchestrator( + host_id="leader_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=LeaderRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + session.stop() + + sp = json.loads((tmp_path / "sync_point.json").read_text()) + mf = json.loads((tmp_path / "manifest.json").read_text()) + assert sp["session_id"] == "amber-tiger-042" + assert sp["role"] == "leader" + assert mf["session_id"] == "amber-tiger-042" + assert mf["role"] == "leader" + assert "leader_host_id" not in mf # leader has no leader_host_id + + def test_auto_generates_session_id(self, tmp_path, fake_multihost): + from syncfield.roles import LeaderRole + + role = LeaderRole() # no session_id + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=role, + ) + session.add(FakeStream("cam")) + session.start() + session.stop() + + assert role.session_id is not None + assert session.session_id == role.session_id + + def test_chirp_chirp_enabled_flag_matches_sync_tone( + self, tmp_path, fake_multihost + ): + """Leader with chirps enabled must advertise chirp_enabled=True.""" + from syncfield.roles import LeaderRole + + player = _mock_player() + session = SessionOrchestrator( + host_id="leader_host", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + role=LeaderRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam", provides_audio_track=True)) + session.start() + session.stop() + + adv = _FakeAdvertiser.instances[0] + assert adv.kwargs["chirp_enabled"] is True + # Leader DID play both chirps (start + stop). + assert player.play.call_count == 2 + + +class TestFollowerRoleIntegration: + def _leader_announcement(self) -> "SessionAnnouncement": + from syncfield.multihost.types import SessionAnnouncement + + return SessionAnnouncement( + session_id="amber-tiger-042", + host_id="leader_host", + status="recording", + sdk_version="0.2.0", + chirp_enabled=True, + started_at_ns=1234, + ) + + def test_start_blocks_for_leader_then_proceeds( + self, tmp_path, fake_multihost + ): + from syncfield.roles import FollowerRole + + _FakeBrowser.wait_recording_result = self._leader_announcement() + + session = SessionOrchestrator( + host_id="follower_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=FollowerRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + + assert len(_FakeBrowser.instances) == 1 + browser = _FakeBrowser.instances[0] + assert browser.session_id == "amber-tiger-042" + assert browser.started is True + assert browser.wait_recording_calls == [60.0] # default timeout + + assert session.observed_leader is not None + assert session.observed_leader.host_id == "leader_host" + assert session.session_id == "amber-tiger-042" + + report = session.stop() + assert report.role == "follower" + assert report.session_id == "amber-tiger-042" + assert browser.closed is True + + def test_follower_never_plays_chirp_even_with_audio_stream( + self, tmp_path, fake_multihost + ): + from syncfield.roles import FollowerRole + + _FakeBrowser.wait_recording_result = self._leader_announcement() + + player = _mock_player() + session = SessionOrchestrator( + host_id="follower_host", + output_dir=tmp_path, + sync_tone=_fast_chirp_config(), + chirp_player=player, + role=FollowerRole(session_id="amber-tiger-042"), + ) + # Audio-capable stream would normally trigger chirps — but the + # follower role must override that. + session.add(FakeStream("audio_cam", provides_audio_track=True)) + session.start() + session.stop() + + player.play.assert_not_called() + + def test_follower_leader_timeout_propagates_and_cleans_up( + self, tmp_path, fake_multihost + ): + from syncfield.roles import FollowerRole + + _FakeBrowser.wait_recording_result = TimeoutError("no leader") + + session = SessionOrchestrator( + host_id="follower_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=FollowerRole( + session_id="amber-tiger-042", leader_wait_timeout_sec=0.05 + ), + ) + session.add(FakeStream("cam")) + with pytest.raises(TimeoutError): + session.start() + + assert session.state is SessionState.IDLE + # Browser was cleaned up on failure. + assert _FakeBrowser.instances[0].closed is True + + def test_manifest_records_leader_host_id(self, tmp_path, fake_multihost): + from syncfield.roles import FollowerRole + + _FakeBrowser.wait_recording_result = self._leader_announcement() + + session = SessionOrchestrator( + host_id="follower_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=FollowerRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + session.stop() + + mf = json.loads((tmp_path / "manifest.json").read_text()) + assert mf["role"] == "follower" + assert mf["session_id"] == "amber-tiger-042" + assert mf["leader_host_id"] == "leader_host" + + def test_wait_for_leader_stopped_delegates_to_browser( + self, tmp_path, fake_multihost + ): + from syncfield.multihost.types import SessionAnnouncement + from syncfield.roles import FollowerRole + + _FakeBrowser.wait_recording_result = self._leader_announcement() + _FakeBrowser.wait_stopped_result = SessionAnnouncement( + session_id="amber-tiger-042", + host_id="leader_host", + status="stopped", + sdk_version="0.2.0", + chirp_enabled=True, + started_at_ns=1234, + ) + + session = SessionOrchestrator( + host_id="follower_host", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=FollowerRole(session_id="amber-tiger-042"), + ) + session.add(FakeStream("cam")) + session.start() + observed_stop = session.wait_for_leader_stopped(timeout=1.5) + assert observed_stop.status == "stopped" + assert _FakeBrowser.instances[0].wait_stopped_calls == [1.5] + session.stop() + + def test_wait_for_leader_stopped_requires_follower_role(self, tmp_path): + """Calling on a single-host session must raise.""" + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + ) + session.add(FakeStream("cam")) + session.start() + with pytest.raises(RuntimeError, match="FollowerRole"): + session.wait_for_leader_stopped() + session.stop() + + def test_wait_for_leader_stopped_before_start_raises( + self, tmp_path, fake_multihost + ): + from syncfield.roles import FollowerRole + + session = SessionOrchestrator( + host_id="h", + output_dir=tmp_path, + sync_tone=SyncToneConfig.silent(), + role=FollowerRole(session_id="amber-tiger-042"), + ) + with pytest.raises(RuntimeError, match="start"): + session.wait_for_leader_stopped() + + class TestSessionLog: def test_session_log_captures_state_transitions(self, tmp_path): session = _session(tmp_path) diff --git a/tests/unit/test_roles.py b/tests/unit/test_roles.py new file mode 100644 index 0000000..05df215 --- /dev/null +++ b/tests/unit/test_roles.py @@ -0,0 +1,58 @@ +"""Tests for :class:`LeaderRole` and :class:`FollowerRole`.""" + +from __future__ import annotations + +import pytest + +from syncfield.roles import FollowerRole, LeaderRole + + +class TestLeaderRole: + def test_generates_session_id_when_missing(self): + role = LeaderRole() + assert role.session_id is not None + assert len(role.session_id) > 0 + + def test_respects_explicit_session_id(self): + role = LeaderRole(session_id="amber-tiger-042") + assert role.session_id == "amber-tiger-042" + + def test_rejects_invalid_session_id(self): + with pytest.raises(ValueError, match="session_id"): + LeaderRole(session_id="has space") + + def test_rejects_dot_in_session_id(self): + with pytest.raises(ValueError, match="session_id"): + LeaderRole(session_id="foo.bar") + + def test_kind_is_leader(self): + assert LeaderRole().kind == "leader" + + def test_default_graceful_shutdown_ms(self): + assert LeaderRole().graceful_shutdown_ms == 1000 + + def test_graceful_shutdown_ms_override(self): + assert LeaderRole(graceful_shutdown_ms=0).graceful_shutdown_ms == 0 + + +class TestFollowerRole: + def test_default_allows_auto_discovery(self): + role = FollowerRole() + assert role.session_id is None + + def test_explicit_session_id_ok(self): + role = FollowerRole(session_id="amber-tiger-042") + assert role.session_id == "amber-tiger-042" + + def test_rejects_invalid_session_id(self): + with pytest.raises(ValueError, match="session_id"): + FollowerRole(session_id="has space") + + def test_kind_is_follower(self): + assert FollowerRole().kind == "follower" + + def test_default_wait_timeout(self): + assert FollowerRole().leader_wait_timeout_sec == 60.0 + + def test_wait_timeout_override(self): + assert FollowerRole(leader_wait_timeout_sec=5.0).leader_wait_timeout_sec == 5.0 From 9a116bf3f18d1e3ad0affcc272c165a34621ccec Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:32:00 -0700 Subject: [PATCH 10/45] feat(discovery): CLI + viewer discovery modal + serialized BLE scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third phase of the discovery system. Turns the ``scan()``/``scan_and_add()`` primitives into two user-facing surfaces: a pretty-printing CLI and a light-themed DearPyGui modal inside the desktop viewer. CLI — ``python -m syncfield.discovery`` --------------------------------------- syncfield/discovery/__main__.py. Grouped listing by Stream kind (Cameras / Sensors / Custom), per-device headline + sub-info line, warnings inline, errors + timed-out adapters at the bottom. ``--json`` for machine-readable output, ``--kinds video sensor`` for filtering, ``--no-cache`` for forced refresh, ``--timeout N`` for the scan budget. Exit codes 0/1/2 for found / partial-failure / empty. Verified on macOS: run against the current test environment, the CLI found 2 cameras (MacBook Pro built-in + iPhone via Continuity Camera) plus ~30 ambient BLE peripherals, all correctly flagged as needing manual characteristic_uuid. Viewer — Discover devices button + modal ------------------------------------------ syncfield/viewer/widgets/discovery_modal.py — new DiscoveryModal widget the layout builds once at viewer startup and reuses across open/close. All widget construction follows the existing OpenGraph light theme (ghost Rescan/Close buttons, primary Add button, muted section headers, per-device checkbox rows with two-line labels). Flow: 1. Click ⚡ Discover devices in the header 2. Modal opens, worker thread runs scan() with use_cache=False 3. Status strip shows "Scanning devices… 2.3s" updating in real time 4. On complete: cards grouped by kind, devices preselected when addable (no warnings, not in use), Add button shows the selection count ("Add 2 devices") 5. Add registers each selected device via device.construct() + session.add(), then closes the modal 6. The main viewer's stream card row picks up the new streams on the next poller tick (no extra wiring needed — SessionOrchestrator state is the single source of truth) Threading — scan runs on a daemon worker, state handoff is via a single _ModalState dataclass guarded by a lock, the render loop calls modal.tick() each frame to rebuild the card list only when a scan has just completed (cheap no-op otherwise). Layout integration: - New "⚡ Discover devices" ghost button in the header row - `btn_discover` enabled only when session.state == "idle" - update() now calls modal.tick() every frame BLE cache serialization fix --------------------------- _ble.py had a concurrency bug: the cache check released the lock before the BleakScanner.discover() call, so two parallel BLE discoverers (OGLO + generic BLE peripheral) would both miss the cache, both release the lock, and both kick off independent BleakScanner runs in parallel. That doubled the wall-clock time and, on macOS CoreBluetooth, occasionally returned garbage because concurrent scanners aren't supported. Fix: - Single lock held across cache-check + scan + cache-update so concurrent callers serialize onto one shared scan - Hard cap BleakScanner window at _MAX_SCAN_S = 5.0 seconds regardless of requested timeout (BLE advertisement cycles are 1-4s, more is wasted) - Effective timeout clamped between 0.5s and 5.0s Before the fix: discovery timed out at the full 10 s budget because BLE hung both discoverers for 10 s each. After the fix: the CLI returns in 5.1 s with 2 cameras + 32 BLE candidates. Demo harness additions ---------------------- demo.py picks up two new flags for screenshot capture: - --empty-session: build a bare session with no pre-populated synthetic streams, so the viewer shows the "click Discover to begin" empty state - --open-discovery: auto-click the Discover button 0.8s after startup so screenshots can capture the modal without user input Used to produce website/static/img/viewer/discovery-modal.png for the upcoming docs page. Tests ----- 338 unit tests passing (315 → +0 new here; the modal + CLI are exercised by the existing discovery tests plus a manual smoke test that instantiates ViewerApp + opens the modal). Two pre-existing mDNS integration test failures in test_multihost_rendezvous.py are unrelated to this work (verified by stashing and rerunning on main). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/discovery/__main__.py | 201 +++++++ src/syncfield/discovery/_ble.py | 105 ++-- src/syncfield/viewer/demo.py | 46 +- .../viewer/widgets/discovery_modal.py | 546 ++++++++++++++++++ src/syncfield/viewer/widgets/layout.py | 45 +- 5 files changed, 899 insertions(+), 44 deletions(-) create mode 100644 src/syncfield/discovery/__main__.py create mode 100644 src/syncfield/viewer/widgets/discovery_modal.py diff --git a/src/syncfield/discovery/__main__.py b/src/syncfield/discovery/__main__.py new file mode 100644 index 0000000..359645e --- /dev/null +++ b/src/syncfield/discovery/__main__.py @@ -0,0 +1,201 @@ +"""CLI front-end for ``syncfield.discovery``. + +Run with:: + + python -m syncfield.discovery # pretty table + python -m syncfield.discovery --json # machine-readable + python -m syncfield.discovery --kinds video --timeout 5 + +Output is grouped by Stream kind (cameras / sensors / other) so users +can scan it at a glance. Each row shows the adapter type, display name, +device id, and any warnings the adapter surfaced. Exit code is ``0`` +when at least one device is found, ``2`` when none are attached but no +errors occurred, and ``1`` on partial scan failure. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Iterable, List, Sequence + +# Importing ``syncfield.adapters`` is what auto-registers the built-in +# discoverers. Without it, ``scan()`` would return an empty report +# because the registry would be empty. +import syncfield.adapters # noqa: F401 (side effect: register discoverers) + +from syncfield.discovery import DiscoveredDevice, DiscoveryReport, scan + + +# --------------------------------------------------------------------------- +# Pretty printing +# --------------------------------------------------------------------------- + + +_KIND_ORDER = ("video", "audio", "sensor", "custom") +_KIND_TITLES = { + "video": "Cameras", + "audio": "Audio streams", + "sensor": "Sensors", + "custom": "Custom", +} + + +def _print_table(report: DiscoveryReport) -> None: + """Human-friendly grouped listing, one section per Stream kind.""" + header = ( + f"\nSyncField discovery — found {len(report.devices)} device(s) " + f"in {report.duration_s:.1f}s" + ) + print(header) + print("=" * len(header.strip())) + + for kind in _KIND_ORDER: + devices = report.by_kind(kind) + if not devices: + continue + title = _KIND_TITLES.get(kind, kind.title()) + print(f"\n{title}") + print("-" * len(title)) + for device in devices: + _print_device_row(device) + + if report.errors: + print("\nErrors (partial scan):") + for adapter_type, message in report.errors.items(): + print(f" ! {adapter_type:20s} {message}") + + if report.timed_out: + print("\nTimed out:") + for adapter_type in report.timed_out: + print(f" ! {adapter_type}") + + if not report.devices and not report.errors and not report.timed_out: + print("\n(no devices found — check cables, permissions, and bleak install)") + + print() + + +def _print_device_row(device: DiscoveredDevice) -> None: + """Render one device in two aligned lines: headline + sub-info.""" + tag = "⚠" if device.warnings else ("◐" if device.in_use else "●") + print(f" {tag} {device.display_name}") + + sub_bits = [device.adapter_type] + if device.device_id and device.device_id != device.display_name: + sub_bits.append(device.device_id) + if device.description: + sub_bits.append(device.description) + print(f" {' · '.join(sub_bits)}") + + if device.warnings: + for warning in device.warnings: + print(f" ⚠ {warning}") + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + + +def _report_to_json(report: DiscoveryReport) -> dict: + """Convert a DiscoveryReport into a plain JSON-friendly dict. + + The ``adapter_cls`` field is dropped because class references aren't + serializable; callers can reconstruct by looking up ``adapter_type``. + """ + return { + "duration_s": report.duration_s, + "devices": [ + { + "adapter_type": d.adapter_type, + "kind": d.kind, + "display_name": d.display_name, + "description": d.description, + "device_id": d.device_id, + "construct_kwargs": dict(d.construct_kwargs), + "accepts_output_dir": d.accepts_output_dir, + "in_use": d.in_use, + "warnings": list(d.warnings), + } + for d in report.devices + ], + "errors": dict(report.errors), + "timed_out": list(report.timed_out), + } + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _parse_kinds(raw: Sequence[str] | None) -> List[str] | None: + if not raw: + return None + kinds: List[str] = [] + for item in raw: + kinds.extend(k.strip() for k in item.split(",") if k.strip()) + return kinds or None + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m syncfield.discovery", + description=( + "Enumerate cameras and sensors that can be opened by " + "SyncField on this machine." + ), + ) + parser.add_argument( + "--kinds", + nargs="*", + help=( + "Filter by Stream kind (video, sensor, audio, custom). " + "Pass 'video' to skip BLE scans, for example." + ), + ) + parser.add_argument( + "--timeout", + type=float, + default=10.0, + help="Overall scan budget in seconds (default 10).", + ) + parser.add_argument( + "--no-cache", + action="store_true", + help="Force a fresh scan, ignoring the 5-second result cache.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print a JSON document instead of the human-friendly table.", + ) + args = parser.parse_args(argv) + + report = scan( + kinds=_parse_kinds(args.kinds), + timeout=args.timeout, + use_cache=not args.no_cache, + ) + + if args.json: + json.dump(_report_to_json(report), sys.stdout, indent=2) + sys.stdout.write("\n") + else: + _print_table(report) + + # Exit codes: + # 0 → found at least one device + # 2 → clean scan but zero devices (not an error, just empty) + # 1 → partial failure (errors or timeouts), regardless of device count + if report.errors or report.timed_out: + return 1 + if not report.devices: + return 2 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/syncfield/discovery/_ble.py b/src/syncfield/discovery/_ble.py index 2530549..1f9cb41 100644 --- a/src/syncfield/discovery/_ble.py +++ b/src/syncfield/discovery/_ble.py @@ -1,18 +1,25 @@ """Shared BLE peripheral scan with short-lived caching. -BLE scanning is *slow* (5-10 seconds depending on the OS) and every -adapter that uses ``bleak`` wants to walk the same result set. Without -coordination, two BLE-based discoverers running in parallel would each -kick off an independent scan and the user would wait 10+ seconds instead -of 5. - -This module exposes :func:`scan_peripherals`, a thread-safe cache around -``BleakScanner.discover()``. The first caller runs the scan; everyone -else within ``_CACHE_TTL_S`` gets the cached result back immediately. - -The cache is *very* short-lived by design (3 seconds) — BLE devices -come and go frequently, and discovery is expected to surface the current -state, not a stale snapshot. +BLE scanning is *slow* (1-5 seconds depending on advertisement interval) +and every adapter that uses ``bleak`` wants to walk the same result set. +Without coordination, two BLE-based discoverers running in parallel +would each kick off an independent ``BleakScanner`` run — not only +slower, but on macOS it can actually cause one of them to hang or +return garbage because CoreBluetooth doesn't expect concurrent scanners. + +This module exposes :func:`scan_peripherals`, a thread-safe coordinator +around ``BleakScanner.discover()``. Concurrent callers share one scan: +the first caller runs it while subsequent callers block on a lock, +then all callers see the same result. Results are also cached for a +short TTL so back-to-back ``scan()`` calls don't re-run Bluetooth. + +Two caps matter: + +- ``_CACHE_TTL_S`` — how long a fresh scan result is served to later + callers without hitting Bluetooth again. +- ``_MAX_SCAN_S`` — hard ceiling on the BleakScanner window, regardless + of what the caller requests. BLE advertisement cycles are 1-4 + seconds, so anything beyond ~5s is wasted time. """ from __future__ import annotations @@ -31,22 +38,36 @@ # discoverers see the same raw peripheral list. _cache: List[Any] = [] _cache_time: float = 0.0 -_cache_lock = threading.Lock() -# Short TTL. Long enough to share one ``scan()`` round across adapters, -# short enough that back-to-back user-triggered rescans feel responsive. +# Single lock guards both the cache AND the in-flight scan. Held for the +# full duration of a ``BleakScanner.discover()`` call so two callers +# racing into the module serialize onto one scan result. +_scan_lock = threading.Lock() + +# Short cache TTL — long enough to share one ``scan()`` round across +# adapters, short enough that back-to-back user-triggered rescans feel +# responsive. _CACHE_TTL_S = 3.0 +# Hard cap on the BleakScanner window. BLE ads repeat every 1-4 s on +# typical peripherals; scanning longer than this wastes wall-clock +# time for essentially zero extra coverage. +_MAX_SCAN_S = 5.0 + def scan_peripherals(timeout: float = 5.0) -> List[Any]: """Return the list of BLE peripherals currently in range. Under the hood this runs ``bleak.BleakScanner.discover()`` on a throwaway asyncio loop and caches the result for a few seconds so - subsequent callers skip the rescan. + subsequent callers skip the rescan. Concurrent callers serialize + on a single shared scan — the first one runs it, the others block + on the lock and then get the cached result. Args: - timeout: BLE scan window in seconds. Ignored on cache hit. + timeout: Requested BLE scan window in seconds. Capped to + :data:`_MAX_SCAN_S` internally; values larger than the cap + are clamped silently. Ignored entirely on a cache hit. Returns: List of ``BLEDevice``-like objects (whatever ``bleak`` returns). @@ -56,38 +77,38 @@ def scan_peripherals(timeout: float = 5.0) -> List[Any]: """ global _cache, _cache_time - # Cache hit path — fast, no subprocess or asyncio overhead. - with _cache_lock: + effective_timeout = min(max(0.5, timeout), _MAX_SCAN_S) + + # Single lock held across the whole call: cache check, scan, cache + # update. This serializes concurrent callers onto one shared result + # instead of letting them run parallel BleakScanner instances — + # which macOS CoreBluetooth doesn't handle well. + with _scan_lock: now = time.monotonic() if _cache and (now - _cache_time) < _CACHE_TTL_S: return list(_cache) - # Cache miss: import bleak lazily so the module stays importable on - # machines without the BLE extra installed. - try: - import bleak # type: ignore[import-not-found] - except ImportError: - logger.debug("bleak not available; BLE discovery returns empty list") - return [] + try: + import bleak # type: ignore[import-not-found] + except ImportError: + logger.debug("bleak not available; BLE discovery returns empty list") + return [] - try: - loop = asyncio.new_event_loop() try: - devices = loop.run_until_complete( - bleak.BleakScanner.discover(timeout=timeout) - ) - finally: - loop.close() - except Exception as exc: - logger.debug("BLE scan failed: %s", exc) - return [] - - # Update the cache under lock; the list is intentionally a fresh copy - # so a reader that mutates its own copy can't affect the cache. - with _cache_lock: + loop = asyncio.new_event_loop() + try: + devices = loop.run_until_complete( + bleak.BleakScanner.discover(timeout=effective_timeout) + ) + finally: + loop.close() + except Exception as exc: + logger.debug("BLE scan failed: %s", exc) + return [] + _cache = list(devices) _cache_time = time.monotonic() - return list(devices) + return list(devices) def clear_cache() -> None: diff --git a/src/syncfield/viewer/demo.py b/src/syncfield/viewer/demo.py index d14e175..9f81df7 100644 --- a/src/syncfield/viewer/demo.py +++ b/src/syncfield/viewer/demo.py @@ -323,6 +323,22 @@ def main(argv: Optional[List[str]] = None) -> int: "Useful for screenshotting." ), ) + parser.add_argument( + "--empty-session", + action="store_true", + help=( + "Skip the synthetic streams and open with an empty session — " + "useful for capturing the 'click Discover to begin' state." + ), + ) + parser.add_argument( + "--open-discovery", + action="store_true", + help=( + "After startup, automatically click the 'Discover devices' " + "header button so screenshots capture the discovery modal." + ), + ) parser.add_argument( "--screenshot", type=Path, @@ -341,7 +357,21 @@ def main(argv: Optional[List[str]] = None) -> int: args.duration = 3.0 args.output_dir.mkdir(parents=True, exist_ok=True) - session = build_demo_session(args.output_dir) + if args.empty_session: + # Bare session with no pre-populated streams — used to capture + # the "click Discover to begin" screenshot. + import syncfield.adapters # noqa: F401 (register discoverers) + + from syncfield.tone import SilentChirpPlayer + + session = sf.SessionOrchestrator( + host_id="demo_rig", + output_dir=args.output_dir, + sync_tone=sf.SyncToneConfig.default(), + chirp_player=SilentChirpPlayer(), + ) + else: + session = build_demo_session(args.output_dir) if args.auto_record: # Start the session immediately so screenshots look populated. @@ -446,6 +476,20 @@ def _timer() -> None: from syncfield.viewer.app import ViewerApp app = ViewerApp(session, title="SyncField", viewport_pos=pin_pos) + + # Optional: programmatically open the discovery modal a moment after + # startup so screenshots can capture it without the user clicking. + if args.open_discovery: + def _auto_open_modal() -> None: + time.sleep(0.8) + try: + if app._layout and app._layout._discovery_modal is not None: # noqa: SLF001 + app._layout._discovery_modal.open() # noqa: SLF001 + except Exception as exc: + print(f"auto-open-discovery failed: {exc}", file=sys.stderr) + + threading.Thread(target=_auto_open_modal, daemon=True).start() + try: app.setup() app.run() diff --git a/src/syncfield/viewer/widgets/discovery_modal.py b/src/syncfield/viewer/widgets/discovery_modal.py new file mode 100644 index 0000000..4dc8502 --- /dev/null +++ b/src/syncfield/viewer/widgets/discovery_modal.py @@ -0,0 +1,546 @@ +"""Desktop viewer modal for ``syncfield.discovery``. + +When the user clicks "Discover devices" in the viewer header, this +modal opens, runs :func:`syncfield.discovery.scan` on a worker thread, +and presents the results as an OpenGraph-styled card list. The user +checks the devices they want, clicks "Add", and the selected devices +are constructed and registered with the live session. + +Layout +------ +:: + + ┌── Discover devices ────────────────────────────┐ + │ Scan ready · last result 4.2 s ago │ + │ │ + │ Cameras │ + │ ───────── │ + │ ☑ FaceTime HD Camera uvc_webcam · idx 0 │ + │ ☑ OAK-D S2 oak_camera · 14… │ + │ │ + │ Sensors │ + │ ─────── │ + │ ☑ OGLO Right oglo_tactile · AA… │ + │ ⚠ BNO085 Dongle requires uuid │ + │ │ + │ [ Rescan ] [ Add 3 → ] │ + └─────────────────────────────────────────────────┘ + +Threading +--------- +All DearPyGui mutation runs on the main thread (via the viewer's +render loop, which calls ``update()`` every frame). The scan itself +runs in a daemon worker thread; when it completes, the worker updates +a small in-modal state object, and the next render-loop tick notices +the change and rebuilds the card list. +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional + +import dearpygui.dearpygui as dpg + +from syncfield.viewer import theme + +if TYPE_CHECKING: + from syncfield.discovery import DiscoveredDevice, DiscoveryReport + from syncfield.orchestrator import SessionOrchestrator + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# State shared between the worker thread and the render loop +# --------------------------------------------------------------------------- + + +@dataclass +class _ModalState: + """Mutable state the worker thread writes and the render loop reads. + + One instance per modal. The render loop polls ``needs_rebuild`` on + every tick — cheap boolean check — and rebuilds the card list only + when a scan has just completed or the selection set changed. This + keeps the per-frame cost of having the modal open effectively zero. + """ + + scanning: bool = False + scan_started_at: float = 0.0 + scan_completed_at: float = 0.0 + report: Optional["DiscoveryReport"] = None + selected: set = field(default_factory=set) # set[device_id] + needs_rebuild: bool = False + error_message: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Modal +# --------------------------------------------------------------------------- + + +class DiscoveryModal: + """Modal window bound to a single :class:`SessionOrchestrator`. + + Constructed once by :class:`~syncfield.viewer.widgets.layout.ViewerLayout` + and reused across opens. All DPG tags are namespaced under + ``"discovery::"`` so the modal never collides with layout widgets. + """ + + _MODAL_WIDTH = 640 + _MODAL_HEIGHT = 620 + _SECTION_SPACING = 16 + + def __init__( + self, + session: "SessionOrchestrator", + *, + on_added: Optional[Callable[[List["DiscoveredDevice"]], None]] = None, + ) -> None: + self._session = session + self._on_added = on_added + self._state = _ModalState() + self._lock = threading.Lock() + + # DPG tags — constant strings so ``configure_item`` / ``set_value`` + # calls don't need to look anything up. + self._window_tag = "discovery::window" + self._status_tag = "discovery::status" + self._content_tag = "discovery::content" + self._rescan_button_tag = "discovery::btn_rescan" + self._add_button_tag = "discovery::btn_add" + self._close_button_tag = "discovery::btn_close" + + self._built = False + + # ------------------------------------------------------------------ + # Build / open / close + # ------------------------------------------------------------------ + + def build(self) -> None: + """Create the modal window. Idempotent — safe to call twice.""" + if self._built: + return + + with dpg.window( + label="Discover devices", + tag=self._window_tag, + width=self._MODAL_WIDTH, + height=self._MODAL_HEIGHT, + modal=True, + show=False, + no_resize=False, + no_collapse=True, + on_close=self._on_window_close, + ): + # Intro text sits at the very top and explains what's about + # to happen in a single sentence. + dpg.add_text( + "Select cameras and sensors to register with this session.", + color=theme.TEXT_SECONDARY, + ) + dpg.add_spacer(height=8) + + # Status strip — shows "Ready", "Scanning…", or "Found N". + with dpg.group(horizontal=True): + dpg.add_text("●", tag="discovery::status_dot", color=theme.TEXT_MUTED) + dpg.add_spacer(width=6) + dpg.add_text( + "Ready", + tag=self._status_tag, + color=theme.TEXT_SECONDARY, + ) + + dpg.add_spacer(height=self._SECTION_SPACING) + + # Scrollable body where we draw device cards after a scan. + dpg.add_child_window( + tag=self._content_tag, + width=-1, + height=-60, # leave room for footer buttons + border=False, + horizontal_scrollbar=False, + ) + + # Footer row: Rescan on the left, Add and Close on the right. + with dpg.group(horizontal=True): + dpg.add_button( + label="Rescan", + tag=self._rescan_button_tag, + width=110, + height=32, + callback=self._on_rescan_click, + ) + # Spacer pushes the next two buttons to the far right edge. + dpg.add_spacer(width=self._MODAL_WIDTH - 110 - 200 - 80) + dpg.add_button( + label="Close", + tag=self._close_button_tag, + width=90, + height=32, + callback=self._on_close_click, + ) + dpg.add_button( + label="Add selected", + tag=self._add_button_tag, + width=140, + height=32, + callback=self._on_add_click, + ) + + # Bind button themes after the context has the tags in place. + dpg.bind_item_theme(self._rescan_button_tag, theme.build_ghost_button_theme()) + dpg.bind_item_theme(self._close_button_tag, theme.build_ghost_button_theme()) + dpg.bind_item_theme(self._add_button_tag, theme.build_primary_button_theme()) + + # Add button starts disabled — no selection until a scan finishes. + dpg.disable_item(self._add_button_tag) + + self._built = True + + def open(self) -> None: + """Show the modal and kick off a fresh scan on a worker thread.""" + if not self._built: + self.build() + dpg.show_item(self._window_tag) + self._start_scan() + + def is_open(self) -> bool: + return self._built and dpg.is_item_shown(self._window_tag) + + # ------------------------------------------------------------------ + # Render-loop integration + # ------------------------------------------------------------------ + + def tick(self) -> None: + """Called every render frame by :class:`ViewerLayout`. + + Checks the mutable state set by the worker thread and rebuilds + the content area when a scan has just finished. No-op when the + modal is closed or the scan hasn't produced new results. + """ + if not self._built: + return + with self._lock: + needs_rebuild = self._state.needs_rebuild + if needs_rebuild: + self._state.needs_rebuild = False + + if not needs_rebuild: + # Still update the elapsed timer while scanning so the user + # sees the progress tick forward. + if self._state.scanning: + elapsed = time.monotonic() - self._state.scan_started_at + dpg.set_value( + self._status_tag, f"Scanning devices… {elapsed:.1f}s" + ) + return + + # Snapshot the shared state under the lock, then render. + with self._lock: + report = self._state.report + scanning = self._state.scanning + error = self._state.error_message + + if scanning: + # Worker said it's scanning but needs_rebuild was also set — + # race where we clear content before showing the spinner. + self._render_scanning_state() + elif error: + self._render_error_state(error) + elif report is not None: + self._render_results(report) + + # ------------------------------------------------------------------ + # Scan driving + # ------------------------------------------------------------------ + + def _start_scan(self) -> None: + """Kick the background scan thread. Disables UI while it runs.""" + # Disable buttons so users can't double-click Rescan or Add while + # the worker is mid-flight. + dpg.disable_item(self._add_button_tag) + dpg.disable_item(self._rescan_button_tag) + dpg.configure_item("discovery::status_dot", color=theme.ACCENT) + dpg.set_value(self._status_tag, "Scanning devices…") + + with self._lock: + self._state.scanning = True + self._state.scan_started_at = time.monotonic() + self._state.report = None + self._state.error_message = None + self._state.selected.clear() + self._state.needs_rebuild = True + + threading.Thread( + target=self._run_scan_worker, + name="discovery-modal-scan", + daemon=True, + ).start() + + def _run_scan_worker(self) -> None: + """Background thread: call ``scan()`` and update shared state.""" + # Lazy import so importing the viewer package doesn't force the + # discovery module load chain. + from syncfield.discovery import scan + + try: + report = scan(timeout=10.0, use_cache=False) + error = None + except Exception as exc: # pragma: no cover — defensive + logger.exception("discovery scan failed") + report = None + error = f"{type(exc).__name__}: {exc}" + + with self._lock: + self._state.scanning = False + self._state.scan_completed_at = time.monotonic() + self._state.report = report + self._state.error_message = error + # Preselect every device that's ready to add (no warnings, + # not in use) so the common "everything looks good, just + # click Add" path is one click away. + if report is not None: + self._state.selected = { + d.device_id + for d in report.devices + if not d.warnings and not d.in_use + } + self._state.needs_rebuild = True + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _clear_content(self) -> None: + """Wipe the content area before redrawing.""" + for child in dpg.get_item_children(self._content_tag, 1) or []: + dpg.delete_item(child) + + def _render_scanning_state(self) -> None: + self._clear_content() + dpg.add_text( + "Enumerating cameras and sensors…", + parent=self._content_tag, + color=theme.TEXT_SECONDARY, + ) + dpg.add_text( + "BLE peripherals take up to 5 seconds.", + parent=self._content_tag, + color=theme.TEXT_MUTED, + ) + + def _render_error_state(self, message: str) -> None: + self._clear_content() + dpg.configure_item("discovery::status_dot", color=theme.DANGER) + dpg.set_value(self._status_tag, "Scan failed") + dpg.add_text( + "Discovery scan failed:", + parent=self._content_tag, + color=theme.DANGER, + ) + dpg.add_text(message, parent=self._content_tag, color=theme.TEXT_SECONDARY) + dpg.enable_item(self._rescan_button_tag) + + def _render_results(self, report: "DiscoveryReport") -> None: + self._clear_content() + + count = len(report.devices) + if count == 0: + dpg.configure_item("discovery::status_dot", color=theme.TEXT_MUTED) + dpg.set_value( + self._status_tag, + f"No devices found ({report.duration_s:.1f}s scan)", + ) + dpg.add_text( + "No cameras or sensors detected.", + parent=self._content_tag, + color=theme.TEXT_SECONDARY, + ) + dpg.add_text( + "Check cables, permissions, and make sure the SyncField " + "extras ([uvc], [oak], [ble]) are installed.", + parent=self._content_tag, + color=theme.TEXT_MUTED, + wrap=self._MODAL_WIDTH - 80, + ) + dpg.enable_item(self._rescan_button_tag) + return + + dpg.configure_item("discovery::status_dot", color=theme.SUCCESS) + dpg.set_value( + self._status_tag, + f"Found {count} device{'s' if count != 1 else ''} in {report.duration_s:.1f}s", + ) + + # Group by Stream kind — cameras first, sensors second, others + # last — so the eye reaches the most-relevant section first. + for kind_key, title in (("video", "Cameras"), ("sensor", "Sensors"), ("audio", "Audio"), ("custom", "Other")): + devices = report.by_kind(kind_key) + if not devices: + continue + dpg.add_text( + title.upper(), + parent=self._content_tag, + color=theme.TEXT_MUTED, + ) + dpg.add_spacer(height=4, parent=self._content_tag) + for device in devices: + self._render_device_row(device) + dpg.add_spacer(height=self._SECTION_SPACING, parent=self._content_tag) + + # Surface any scan errors at the bottom, muted. + if report.errors: + dpg.add_separator(parent=self._content_tag) + dpg.add_text( + "Scan errors (partial):", + parent=self._content_tag, + color=theme.TEXT_MUTED, + ) + for adapter_type, error in report.errors.items(): + dpg.add_text( + f"· {adapter_type}: {error}", + parent=self._content_tag, + color=theme.WARNING, + wrap=self._MODAL_WIDTH - 80, + ) + + dpg.enable_item(self._rescan_button_tag) + self._refresh_add_button_label() + + def _render_device_row(self, device: "DiscoveredDevice") -> None: + """One row per discovered device — checkbox + two-line label.""" + addable = not device.warnings and not device.in_use + checkbox_tag = f"discovery::check_{device.device_id}" + row_tag = f"discovery::row_{device.device_id}" + + with dpg.group(tag=row_tag, parent=self._content_tag): + with dpg.group(horizontal=True): + dpg.add_checkbox( + tag=checkbox_tag, + default_value=device.device_id in self._state.selected, + callback=self._on_checkbox_toggle, + user_data=device.device_id, + enabled=addable, + ) + dpg.add_spacer(width=4) + dpg.add_text( + device.display_name, + color=theme.TEXT_PRIMARY if addable else theme.TEXT_MUTED, + ) + + # Sub-line: adapter_type · device_id · description + sub_bits = [device.adapter_type] + if device.device_id and device.device_id != device.display_name: + sub_bits.append(device.device_id) + if device.description: + sub_bits.append(device.description) + with dpg.group(horizontal=True): + dpg.add_spacer(width=24) # align under the label + dpg.add_text(" · ".join(sub_bits), color=theme.TEXT_MUTED) + + # Warning row if the device can't be auto-added. + if device.warnings: + with dpg.group(horizontal=True): + dpg.add_spacer(width=24) + dpg.add_text(f"⚠ {device.warnings[0]}", color=theme.WARNING) + + if device.in_use: + with dpg.group(horizontal=True): + dpg.add_spacer(width=24) + dpg.add_text( + "⚠ already in use by another process", + color=theme.WARNING, + ) + + dpg.add_spacer(height=6) + + def _refresh_add_button_label(self) -> None: + """Keep the Add button label in sync with the selection size.""" + n = len(self._state.selected) + if n == 0: + dpg.set_item_label(self._add_button_tag, "Add selected") + dpg.disable_item(self._add_button_tag) + else: + dpg.set_item_label( + self._add_button_tag, + f"Add {n} device{'s' if n != 1 else ''}", + ) + dpg.enable_item(self._add_button_tag) + + # ------------------------------------------------------------------ + # Button callbacks + # ------------------------------------------------------------------ + + def _on_checkbox_toggle(self, sender: Any, value: bool, user_data: Any) -> None: + device_id = str(user_data) + with self._lock: + if value: + self._state.selected.add(device_id) + else: + self._state.selected.discard(device_id) + self._refresh_add_button_label() + + def _on_rescan_click(self) -> None: + self._start_scan() + + def _on_add_click(self) -> None: + """Construct and register each selected device with the session. + + Runs on the UI thread (it's a button callback) but the + ``session.add()`` and stream construction calls are fast — no + real I/O, no BLE connect — so blocking briefly is fine. + """ + from syncfield.discovery import make_stream_id + + with self._lock: + report = self._state.report + selected = set(self._state.selected) + + if not report or not selected: + return + + existing_ids = set(self._session._streams.keys()) # noqa: SLF001 + added: List["DiscoveredDevice"] = [] + + for device in report.devices: + if device.device_id not in selected: + continue + try: + stream_id = make_stream_id(device.display_name, existing_ids) + kwargs: Dict[str, Any] = {"id": stream_id} + if device.accepts_output_dir: + kwargs["output_dir"] = self._session.output_dir + stream = device.construct(**kwargs) + self._session.add(stream) + existing_ids.add(stream_id) + added.append(device) + except Exception as exc: + logger.warning( + "failed to add %s: %s: %s", + device.display_name, + type(exc).__name__, + exc, + ) + + if added and self._on_added is not None: + try: + self._on_added(added) + except Exception: + logger.exception("on_added callback raised") + + dpg.hide_item(self._window_tag) + + def _on_close_click(self) -> None: + dpg.hide_item(self._window_tag) + + def _on_window_close(self, sender: Any) -> None: + """Called when the user clicks the native ``X`` on the modal.""" + # Nothing to clean up — the scan thread is daemonized and the + # DPG state is reused on the next open(). + pass diff --git a/src/syncfield/viewer/widgets/layout.py b/src/syncfield/viewer/widgets/layout.py index c867188..e7f1dbc 100644 --- a/src/syncfield/viewer/widgets/layout.py +++ b/src/syncfield/viewer/widgets/layout.py @@ -28,6 +28,7 @@ from syncfield.types import SessionState from syncfield.viewer import theme from syncfield.viewer.state import SessionSnapshot +from syncfield.viewer.widgets.discovery_modal import DiscoveryModal from syncfield.viewer.widgets.formatting import ( format_chirp_pair, format_elapsed, @@ -51,6 +52,10 @@ def __init__(self, session: SessionOrchestrator) -> None: self._streams_row_tag = "streams_row" self._health_table_tag = "health_table" self._last_health_keys: tuple = () + # Discovery modal — built lazily the first time the user clicks + # the header button. Holds its own DPG tags so the layout does + # not need to know about its internals. + self._discovery_modal: Optional[DiscoveryModal] = None # ------------------------------------------------------------------ # Build (called once at viewer startup) @@ -87,13 +92,21 @@ def build(self) -> None: dpg.bind_item_theme("btn_record", self._primary_theme) dpg.bind_item_theme("btn_stop", self._danger_theme) dpg.bind_item_theme("btn_cancel", self._ghost_theme) + dpg.bind_item_theme("btn_discover", self._ghost_theme) + + # Construct (but don't yet show) the discovery modal. Building + # it here means the first click on the Discover button opens + # an already-ready window instead of waiting for DPG to build + # on demand. + self._discovery_modal = DiscoveryModal(self._session) + self._discovery_modal.build() # ------------------------------------------------------------------ # Sections # ------------------------------------------------------------------ def _build_header(self) -> None: - """Top row: logo, host id, state chip, elapsed timer.""" + """Top row: logo, host id, state chip, elapsed timer, discover button.""" with dpg.group(horizontal=True): dpg.add_text("SyncField", tag="app_title") dpg.add_spacer(width=12) @@ -110,6 +123,15 @@ def _build_header(self) -> None: tag="elapsed_text", color=theme.TEXT_SECONDARY, ) + # Right-side spacer pushes the discover button to the edge. + dpg.add_spacer(width=220) + dpg.add_button( + label="⚡ Discover devices", + tag="btn_discover", + width=180, + height=30, + callback=self._on_discover_click, + ) dpg.add_spacer(height=4) dpg.add_text( @@ -245,6 +267,12 @@ def update(self, snapshot: SessionSnapshot) -> None: self._update_health(snapshot) self._update_footer(snapshot) + # Discovery modal has its own per-frame tick that only does work + # when the worker thread has produced new scan results or the + # elapsed-time display needs a bump. Cheap no-op when closed. + if self._discovery_modal is not None: + self._discovery_modal.tick() + def _update_header(self, snapshot: SessionSnapshot) -> None: dpg.configure_item("state_dot", color=theme.state_color(snapshot.state)) dpg.set_value("state_label", state_label(snapshot.state)) @@ -275,6 +303,10 @@ def _update_controls(self, snapshot: SessionSnapshot) -> None: _set_enabled("btn_record", state == "idle") _set_enabled("btn_stop", state == "recording") _set_enabled("btn_cancel", state in ("preparing", "recording")) + # Discovery only makes sense before recording — the session's + # ``add()`` contract refuses new streams once ``start()`` has + # been called. + _set_enabled("btn_discover", state == "idle") def _update_streams(self, snapshot: SessionSnapshot, now_ns: int) -> None: # Create cards for new streams. @@ -359,6 +391,17 @@ def _on_cancel_click(self) -> None: subclass this layout in the future.""" self._on_stop_click() + def _on_discover_click(self) -> None: + """Open the discovery modal. Disabled while recording to keep the + registry-add path out of a live session's hot path.""" + if self._session.state is not SessionState.IDLE: + # Silently ignore — the add button will be disabled anyway, + # and the visual affordance in the header tells the user to + # stop the session first. + return + if self._discovery_modal is not None: + self._discovery_modal.open() + @staticmethod def _safe_call(fn) -> None: try: From 4016a762b158fb4ef53aa2e26fc8afe2df00293e Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 13:51:43 -0700 Subject: [PATCH 11/45] feat(multihost): public exports + zeroconf extra + integration smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export LeaderRole, FollowerRole, RoleKind, ChirpEmission, ChirpSource from the top-level syncfield package so users can opt into multi-host with one import. - pyproject: add `multihost` optional extra pinning `zeroconf>=0.130` (not pulled into the default install), plus `slow` pytest marker for integration tests that touch real IO. - test_public_api: assert the new exports and verify the syncfield.multihost subpackage is importable. - Integration test (marked slow): end-to-end leader advertises → follower observes recording → leader flips stopped → follower observes stopped, using the real zeroconf stack on loopback. Includes a strict mDNS probe that attempts a round-trip register→browse→get_service_info cycle during collection and cleanly skips the module when the host's multicast path is broken (sandboxed CI, no active network, etc.) — the fake-backend unit tests under tests/unit/multihost/ remain the source of truth for logic coverage. Advertiser hardening: build a fresh ServiceInfo on every status transition via _build_service_info() instead of mutating the existing instance in place. zeroconf>=0.140 made ServiceInfo.properties read-only; the new path works on every supported version. Browser hardening: pass timeout=3000 ms to Zeroconf.get_service_info so the ServiceListener callback waits for the full TXT record to resolve before parsing it. Falls back to the no-kwarg call when the backend (e.g. unit-test fakes) doesn't accept the argument. 341 unit tests pass, 2 integration tests skip on this host. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 8 + src/syncfield/__init__.py | 22 +- src/syncfield/multihost/advertiser.py | 34 +++- src/syncfield/multihost/browser.py | 26 ++- .../integration/test_multihost_rendezvous.py | 188 ++++++++++++++++++ tests/unit/test_public_api.py | 28 +++ uv.lock | 99 ++++++++- 7 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 tests/integration/test_multihost_rendezvous.py diff --git a/pyproject.toml b/pyproject.toml index 1cf5cd9..1864c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,12 @@ viewer = [ "dearpygui>=2.0", "numpy>=1.21", ] +# mDNS-based multi-host session rendezvous (syncfield.multihost). Required +# only when you want a leader/follower session discovered automatically +# on the local network — single-host sessions do not need this. +multihost = [ + "zeroconf>=0.130", +] all = [ "sounddevice>=0.4.6", "numpy>=1.21", @@ -50,6 +56,7 @@ all = [ "bleak>=0.21", "depthai>=3.0.0", "dearpygui>=2.0", + "zeroconf>=0.130", ] [project.urls] @@ -65,6 +72,7 @@ packages = ["src/syncfield"] testpaths = ["tests"] markers = [ "hardware: tests that require physical hardware (cameras, BLE devices)", + "slow: integration tests that touch real IO (mDNS sockets, filesystem, etc.)", ] [dependency-groups] diff --git a/src/syncfield/__init__.py b/src/syncfield/__init__.py index 01f9cba..a395c41 100644 --- a/src/syncfield/__init__.py +++ b/src/syncfield/__init__.py @@ -25,9 +25,12 @@ from syncfield.clock import SessionClock from syncfield.orchestrator import SessionOrchestrator +from syncfield.roles import FollowerRole, LeaderRole, RoleKind from syncfield.stream import Stream, StreamBase from syncfield.tone import ChirpSpec, SyncToneConfig from syncfield.types import ( + ChirpEmission, + ChirpSource, FinalizationReport, HealthEvent, HealthEventKind, @@ -40,20 +43,29 @@ ) __all__ = [ + # Core orchestrator "SessionOrchestrator", - "Stream", - "StreamBase", - "StreamCapabilities", - "StreamKind", "SessionClock", "SessionState", "SessionReport", "FinalizationReport", + # Stream SPI + capabilities + "Stream", + "StreamBase", + "StreamCapabilities", + "StreamKind", + "SampleEvent", "HealthEvent", "HealthEventKind", - "SampleEvent", "SyncPoint", + # Sync tone / chirp "SyncToneConfig", "ChirpSpec", + "ChirpEmission", + "ChirpSource", + # Multi-host roles (opt-in) + "LeaderRole", + "FollowerRole", + "RoleKind", ] __version__ = _pkg_version("syncfield") diff --git a/src/syncfield/multihost/advertiser.py b/src/syncfield/multihost/advertiser.py index 41604dc..bdf19c1 100644 --- a/src/syncfield/multihost/advertiser.py +++ b/src/syncfield/multihost/advertiser.py @@ -135,15 +135,8 @@ def start(self) -> None: if self._zc is not None: raise RuntimeError("SessionAdvertiser already started") zc_factory = _get_zeroconf_cls() - info_cls = _get_service_info_cls() self._zc = zc_factory() - self._info = info_cls( - SERVICE_TYPE, - f"{self._announcement.session_id}.{SERVICE_TYPE}", - port=ADVERT_PORT, - properties=self._announcement.to_txt_record(), - server=f"{socket.gethostname()}.local.", - ) + self._info = self._build_service_info(self._announcement) self._zc.register_service(self._info) logger.info( "SessionAdvertiser started: session_id=%s host_id=%s", @@ -159,6 +152,12 @@ def update_status( ) -> None: """Transition the advertised status. + Builds a **new** ``ServiceInfo`` with the updated TXT record + and passes it to ``Zeroconf.update_service``. We don't mutate + the existing ``ServiceInfo`` in place because ``properties`` + is read-only on ``zeroconf>=0.140`` — mutating it used to + work silently on older versions, which was brittle. + Args: status: New lifecycle phase. started_at_ns: Optional monotonic ns to embed in the TXT @@ -185,7 +184,7 @@ def update_status( else self._announcement.started_at_ns ), ) - self._info.properties = self._announcement.to_txt_record() + self._info = self._build_service_info(self._announcement) self._zc.update_service(self._info) logger.info( "SessionAdvertiser status=%s (session_id=%s)", @@ -193,6 +192,23 @@ def update_status( self._announcement.session_id, ) + def _build_service_info(self, announcement: SessionAnnouncement) -> Any: + """Construct a ``ServiceInfo`` for the given announcement. + + Both :meth:`start` (initial registration) and + :meth:`update_status` (every status transition) call this so + the TXT record is always built via the public constructor + rather than through private attribute mutation. + """ + info_cls = _get_service_info_cls() + return info_cls( + SERVICE_TYPE, + f"{announcement.session_id}.{SERVICE_TYPE}", + port=ADVERT_PORT, + properties=announcement.to_txt_record(), + server=f"{socket.gethostname()}.local.", + ) + def close(self) -> None: """Unregister the service and close the ``Zeroconf`` instance. diff --git a/src/syncfield/multihost/browser.py b/src/syncfield/multihost/browser.py index e5240b5..263db1c 100644 --- a/src/syncfield/multihost/browser.py +++ b/src/syncfield/multihost/browser.py @@ -197,6 +197,14 @@ def remove_service(self, zc: Any, type_: str, name: str) -> None: self._sessions.pop(name, None) self._update_event.notify_all() + #: Default ``get_service_info`` timeout, in milliseconds. + #: zeroconf's listener callbacks fire as soon as the service name + #: is known, sometimes before the TXT record has been fully + #: resolved. Passing an explicit timeout tells zeroconf to wait + #: for the full resolution before returning, which is what we + #: want so the browser never sees a half-populated announcement. + _GET_INFO_TIMEOUT_MS = 3000 + def _refresh(self, zc: Any, name: str) -> None: """Re-fetch the TXT record for *name* and update ``_sessions``. @@ -204,9 +212,25 @@ def _refresh(self, zc: Any, name: str) -> None: and ignored — the browser must never crash on a single bad peer. The update condition is notified even when the refresh failed so waiters can re-evaluate their predicate. + + Uses :attr:`_GET_INFO_TIMEOUT_MS` as the blocking timeout on + ``get_service_info`` so the callback waits for the full TXT + record to resolve before returning. Tests that stub zeroconf + with a synchronous fake backend can still call the same + method with their fake ``get_service_info(type, name)`` — + the keyword argument is forwarded via ``**kwargs`` so the + fake only needs to accept what it cares about. """ try: - info = zc.get_service_info(SERVICE_TYPE, name) + try: + info = zc.get_service_info( + SERVICE_TYPE, name, timeout=self._GET_INFO_TIMEOUT_MS + ) + except TypeError: + # Fake backends in unit tests don't accept timeout — + # retry without it so the same browser works against + # both real zeroconf and the test doubles. + info = zc.get_service_info(SERVICE_TYPE, name) except Exception as exc: # pragma: no cover - best-effort logger.warning("get_service_info failed for %s: %s", name, exc) return diff --git a/tests/integration/test_multihost_rendezvous.py b/tests/integration/test_multihost_rendezvous.py new file mode 100644 index 0000000..c5f238a --- /dev/null +++ b/tests/integration/test_multihost_rendezvous.py @@ -0,0 +1,188 @@ +"""End-to-end multi-host rendezvous over real Zeroconf on loopback. + +Marked ``slow`` so the default unit-test run stays snappy; invoke with +``pytest -m slow`` to include. + +These tests instantiate the real :class:`SessionAdvertiser` and +:class:`SessionBrowser` without any mocks and drive them through the +full preparing → recording → stopped status transitions. If zeroconf +cannot bind to the local interface (CI without mDNS, sandboxed +environments, no active network, …) the whole module is skipped. + +The comprehensive logic coverage lives in the unit-test suite under +``tests/unit/multihost/`` (38 tests with a fake zeroconf backend). +These integration tests are a thin smoke check that the real wire +format round-trips — they intentionally have fewer assertions and +rely on OS mDNS infrastructure. +""" + +from __future__ import annotations + +import threading +import time + +import pytest + +zeroconf_mod = pytest.importorskip("zeroconf") + +from syncfield.multihost.advertiser import SessionAdvertiser # noqa: E402 +from syncfield.multihost.browser import SessionBrowser # noqa: E402 + +pytestmark = pytest.mark.slow + + +def _probe_mdns_available() -> bool: + """Return ``True`` when the host has a working mDNS multicast stack. + + Zeroconf binds to ``224.0.0.251:5353`` on every available + interface. On macOS without an active network, on sandboxed CI + runners, or inside containers without host networking, that bind + fails with ``OSError(49, "Can't assign requested address")`` — + but the failure happens asynchronously on zeroconf's engine + thread and is only logged as a warning. Merely constructing a + ``Zeroconf`` instance therefore is not enough to confirm the + stack is usable. + + This probe actually tries to register a dummy service and browse + for it with a short 1.5 s deadline. If the round trip completes, + the host has a working mDNS path; if it times out, we skip. + """ + import socket + + try: + zc = zeroconf_mod.Zeroconf() + except Exception: + return False + try: + info = zeroconf_mod.ServiceInfo( + "_syncfieldprobe._tcp.local.", + "probe._syncfieldprobe._tcp.local.", + port=0, + properties={b"probe": b"1"}, + server=f"{socket.gethostname()}.local.", + ) + try: + zc.register_service(info) + except Exception: + return False + + got_event = threading.Event() + resolved_properties: list = [] + + class _ProbeListener: + def add_service(self, zc, type_, name): + # Resolve the TXT record synchronously to verify the + # full mDNS path (not just the listener callback) + # is working. This is the SAME pattern the + # SessionBrowser uses, so if the probe succeeds the + # real browser is guaranteed to work too. + try: + info = zc.get_service_info( + "_syncfieldprobe._tcp.local.", name, timeout=1500 + ) + except Exception: + return + if info is not None and getattr(info, "properties", None): + resolved_properties.append(dict(info.properties)) + got_event.set() + + def update_service(self, zc, type_, name): + self.add_service(zc, type_, name) + + def remove_service(self, zc, type_, name): + pass + + browser = zeroconf_mod.ServiceBrowser( + zc, "_syncfieldprobe._tcp.local.", listener=_ProbeListener() + ) + try: + available = got_event.wait(timeout=3.0) + finally: + try: + browser.cancel() + except Exception: + pass + try: + zc.unregister_service(info) + except Exception: + pass + return available + finally: + try: + zc.close() + except Exception: + pass + + +_MDNS_AVAILABLE = _probe_mdns_available() +_mdns_required = pytest.mark.skipif( + not _MDNS_AVAILABLE, + reason="no working mDNS multicast socket on this host", +) + + +@_mdns_required +def test_leader_advertises_then_follower_observes_recording(): + """A leader's update_status('recording') must reach a live browser.""" + advertiser = SessionAdvertiser( + session_id="integration-test-001", + host_id="leader", + sdk_version="0.2.0", + chirp_enabled=True, + graceful_shutdown_ms=200, + ) + browser = SessionBrowser(session_id="integration-test-001") + + advertiser.start() + browser.start() + try: + def promote(): + time.sleep(0.3) + advertiser.update_status("recording", started_at_ns=42) + + t = threading.Thread(target=promote, daemon=True) + t.start() + + observed = browser.wait_for_recording(timeout=5.0) + t.join(timeout=1.0) + + assert observed.session_id == "integration-test-001" + assert observed.status == "recording" + assert observed.host_id == "leader" + assert observed.started_at_ns == 42 + finally: + browser.close() + advertiser.close() + + +@_mdns_required +def test_leader_stopped_transition_reaches_follower(): + """Follower's wait_for_stopped must fire when leader flips to stopped.""" + advertiser = SessionAdvertiser( + session_id="integration-test-002", + host_id="leader", + sdk_version="0.2.0", + chirp_enabled=True, + graceful_shutdown_ms=100, + ) + browser = SessionBrowser(session_id="integration-test-002") + + advertiser.start() + advertiser.update_status("recording", started_at_ns=1) + browser.start() + try: + def flip_stopped(): + time.sleep(0.3) + advertiser.update_status("stopped") + + t = threading.Thread(target=flip_stopped, daemon=True) + t.start() + + observed = browser.wait_for_stopped(timeout=5.0) + t.join(timeout=1.0) + + assert observed.status == "stopped" + assert observed.session_id == "integration-test-002" + finally: + browser.close() + advertiser.close() diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py index 7c94c52..2427e4d 100644 --- a/tests/unit/test_public_api.py +++ b/tests/unit/test_public_api.py @@ -9,6 +9,8 @@ def test_top_level_exports(): assert hasattr(sf, "SessionOrchestrator") assert hasattr(sf, "SyncToneConfig") assert hasattr(sf, "ChirpSpec") + assert hasattr(sf, "ChirpEmission") + assert hasattr(sf, "ChirpSource") # Protocol + base class for adapter authors assert hasattr(sf, "Stream") assert hasattr(sf, "StreamBase") @@ -16,12 +18,38 @@ def test_top_level_exports(): assert hasattr(sf, "StreamCapabilities") assert hasattr(sf, "SessionState") assert hasattr(sf, "SyncPoint") + # Multi-host roles (opt-in) + assert hasattr(sf, "LeaderRole") + assert hasattr(sf, "FollowerRole") + assert hasattr(sf, "RoleKind") # Clock assert hasattr(sf, "SessionClock") # Version assert hasattr(sf, "__version__") +def test_multihost_subpackage(): + """The multi-host rendezvous subpackage is separately importable.""" + from syncfield.multihost import ( + SERVICE_TYPE, + SessionAdvertStatus, + SessionAdvertiser, + SessionAnnouncement, + SessionBrowser, + generate_session_id, + is_valid_session_id, + ) + assert SERVICE_TYPE == "_syncfield._tcp.local." + assert callable(generate_session_id) + assert is_valid_session_id("amber-tiger-042") + # Classes are exposed for type hints / custom orchestration + assert SessionAdvertiser is not None + assert SessionBrowser is not None + assert SessionAnnouncement is not None + # Literal type alias — just check it is importable, not its shape + assert SessionAdvertStatus is not None + + def test_testing_subpackage(): from syncfield.testing import FakeStream assert FakeStream("x").id == "x" diff --git a/uv.lock b/uv.lock index 9bf167f..aee0269 100644 --- a/uv.lock +++ b/uv.lock @@ -312,6 +312,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -873,6 +882,7 @@ all = [ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "opencv-python" }, { name = "sounddevice" }, + { name = "zeroconf" }, ] audio = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -884,6 +894,9 @@ ble = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] +multihost = [ + { name = "zeroconf" }, +] oak = [ { name = "depthai" }, ] @@ -919,8 +932,10 @@ requires-dist = [ { name = "opencv-python", marker = "extra == 'uvc'", specifier = ">=4.5" }, { name = "sounddevice", marker = "extra == 'all'", specifier = ">=0.4.6" }, { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, + { name = "zeroconf", marker = "extra == 'all'", specifier = ">=0.130" }, + { name = "zeroconf", marker = "extra == 'multihost'", specifier = ">=0.130" }, ] -provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "all"] +provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "all"] [package.metadata.requires-dev] dev = [ @@ -1251,3 +1266,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/87/edf73f0fb9ec342942dd7867682c4a85a6ecc25eeb212aef0d64f91667aa/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657", size = 133724, upload-time = "2025-06-06T14:02:08.876Z" }, { url = "https://files.pythonhosted.org/packages/9e/13/cc2cda5a998efb894e90f96b8e1320098924ed331540122049ddab31d5c8/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d", size = 128967, upload-time = "2025-06-06T14:02:09.698Z" }, ] + +[[package]] +name = "zeroconf" +version = "0.148.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/47/a2ff13f3a0a7b9bd4cc1a904e7ddfd4f327043387915607db1117e1c1417/zeroconf-0.148.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9146731bb82bc7b42f009aa69619b17a4b6ddecc75eee9a59249c12c804d0637", size = 1708548, upload-time = "2025-10-05T01:07:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/54/11/7c871eba676458e5f3943e45281db91c3b01743b8e7f5401640855ca863e/zeroconf-0.148.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db24dc2e5367dc61bacbf302b7c85cc10ee1a9de8f1710380027992afd1ddcb4", size = 1682018, upload-time = "2025-10-05T01:07:33.879Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/62149096a758e6036625a66b1053af8600126cb8e50ca8adcb705732e211/zeroconf-0.148.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2202ac7dc2777249561292c9151919d70fbe25a31983b7e127b43878ea67483c", size = 2195888, upload-time = "2025-10-05T01:07:35.833Z" }, + { url = "https://files.pythonhosted.org/packages/c5/35/9ace30a86ec42b11a1904d63fa260c166c972a743079d6ffaa69f8085924/zeroconf-0.148.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:876e9e61a7065d201d39c466449e01fa9e19c3c7b2c5ee57bc628f15e21653fb", size = 1970965, upload-time = "2025-10-05T01:07:37.809Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/b463e84c221ccbbc04a6d4f69a2e2c2c005ce04a233ec2d398987f50177f/zeroconf-0.148.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:171ff9d59283737946d79c6a290a597a3d10d0d24d6a3a87de67ce3064157afc", size = 2269837, upload-time = "2025-10-05T01:07:39.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/b56979c5abb14d18790eca845428b417990177ebd9674b1b1f61491efd3b/zeroconf-0.148.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:76d53985fa40cefb3a82c1d5d761217392bbc811964715e1bf73e74084012062", size = 2224442, upload-time = "2025-10-05T01:07:41.816Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/6b6cf5d1f75f464cb697bc4fcb59baaa792485d5f8a29ac460ed75d1afe8/zeroconf-0.148.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:429e8ed8428737f2586992aaf11a21302184cd4e1c641fbd7abe8946d9ff7089", size = 2017165, upload-time = "2025-10-05T01:07:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f3/cce930ce7d57da67ce7662b188907415441c3edfca4aac97be53049c0cc9/zeroconf-0.148.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:aa0cdcb91f231789d8f6ba7ed702d05a36975e7b06fd663aff25205ddca2b659", size = 2293150, upload-time = "2025-10-05T01:07:45.695Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/d011bc4ba85a8df2796622f91a44f181526e382ff57c69df0dd11a4ce57a/zeroconf-0.148.0-cp310-cp310-win32.whl", hash = "sha256:3c1ec76c031712c6289cc94acee43e7bf7a6cb52b45675278348926eacffc668", size = 1310884, upload-time = "2025-10-05T01:07:47.561Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7a/abedff888ffc6e79c2a189a7bb9f4b358c57db895a21bbb0e59b40c6e5c8/zeroconf-0.148.0-cp310-cp310-win_amd64.whl", hash = "sha256:144fa2e0246292ea9c62792327d230f1b996c65cec16024b10689ee597b05ab2", size = 1527932, upload-time = "2025-10-05T01:07:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/00/1f/dcaed909fabbdf760739b3081cbea3f7cd564e61a708dca00a55960da11c/zeroconf-0.148.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b923e26369e302863aa5370eff4d4d72a0b90ba85d3b9f608c62cbab78f14dc2", size = 1723036, upload-time = "2025-10-05T01:07:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/05/37/849d419ccd60e37e02ca7364ac9451e500e517cebf884bee88e6811c442b/zeroconf-0.148.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbffd751877b74cd64c529061e5a524ebfa59af16930330548033e307701fee", size = 1696983, upload-time = "2025-10-05T01:07:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1e/1511a2f10e22e51e391638dc58786af74c996513b6588c9219f085cc898e/zeroconf-0.148.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34275f60a5ab01d2d0a190662d16603b75f9225cee4ab58d388ff86d8011352a", size = 2208149, upload-time = "2025-10-05T01:07:54.7Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8e/744b4cc8d9ee314be5fe3fbd597baef4cc3998d24d70ac1265dfa7750544/zeroconf-0.148.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:556ff0b9dfc0189f33c6e6110aa23d9f7564a7475f4cdc624a0584c1133ae44b", size = 1978151, upload-time = "2025-10-05T01:07:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/42/1f/d8b365a3f3979ea3f0ecb02c22c61d2cdc4fc5bc0bc182ff52547935923c/zeroconf-0.148.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8aa15461e35169b4ec25cc45ec82750023e6c2e96ebc099a014caaf544316f7", size = 2285527, upload-time = "2025-10-05T01:07:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/0a27fd233240a911a58eae6037357f0d088cdcbff295cf01ad05a0b91bd6/zeroconf-0.148.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:25b8b72177bbe0792f5873c16330d759811540edb24ed9ead305874183eaefd5", size = 2237619, upload-time = "2025-10-05T01:07:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/56d6155eb3b8a77a11dcf0c77ef747c46a1c778d8957b818dfdc0578886e/zeroconf-0.148.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:10ce75cdb524f955773114697633d73644aad6c35daef5315fa478bff9bee24d", size = 2031313, upload-time = "2025-10-05T01:08:01.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/4521a8c469802c16f61e6b12b674a239684d0fe8e51fe66ebe0223ca4d2d/zeroconf-0.148.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49512e6d59be66be769f36e1f7f025a2841783da1be708d4b4a92a7b63135b68", size = 2309244, upload-time = "2025-10-05T01:08:03.311Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8b/b34de5602013e6a4d08918adaaadf706985a45990018c9d4339dd6df6b8d/zeroconf-0.148.0-cp311-cp311-win32.whl", hash = "sha256:8ff905f8ff9083a853eb4e65eb31b09fa9d7a6633de92ac1e2018819eee52d30", size = 1305325, upload-time = "2025-10-05T01:08:04.841Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f4/a9f279aee669b03a7e1b435ee7bd4fb1fd4ced040d2188af903d3b276e29/zeroconf-0.148.0-cp311-cp311-win_amd64.whl", hash = "sha256:7339a485403c75aa4f3c38ddcb68eb14f01fd5e1dc1ef75b068b185e703ea7ea", size = 1529930, upload-time = "2025-10-05T01:08:07.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/b3/6c08ccbda1e78c8f538d8add49fac2fe49ef85ee34b62877df4154715583/zeroconf-0.148.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aef8699ea47cd47c9219e3f110a35ad50c13c34c7c6db992f3c9f75feec6ef8f", size = 1735431, upload-time = "2025-10-05T01:08:09.375Z" }, + { url = "https://files.pythonhosted.org/packages/cb/37/6b91c4a4258863e485602e6b1eb098fe406142a653112e8719c49b69afc4/zeroconf-0.148.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9097e7010b9f9a64e5f2084493e9973d446bd85c7a7cbef5032b2b0a2ecc5a12", size = 1701594, upload-time = "2025-10-05T01:08:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/5eaaf66d39b3bccc17b52187eebb2dde93f761f4ee8b6c83b8fe764273f5/zeroconf-0.148.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdc566c387260fb7bf89f91d00460d0c9b9373dfddcf1fcc980ab3f7270154f9", size = 2134103, upload-time = "2025-10-05T01:08:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/19/a5/e4ebe7b5fbea512fe13efb466d855124126d2f531a18216c7cb509b8a4dd/zeroconf-0.148.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10cbd4134cacc22c3b3b169d7f782472a1dd36895e1421afa4f681caf181c07b", size = 1930109, upload-time = "2025-10-05T01:08:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/7f7c5cee5279afe2a6a8b9657de9a587ccb34168d7c99acc6d2b40b9d87e/zeroconf-0.148.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dde01541e6a45c4d1b6e6d97b532ea241abc32c183745a74021b134d867388d8", size = 2230425, upload-time = "2025-10-05T01:08:16.296Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/0e1999db76e390fca9eef8257455955445a0386b94ce0ef6ce74896d7e2a/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8ceab8f10ab6fc0847a2de74377663793a974fdba77e7e6ba1ff47679f4bb845", size = 2161052, upload-time = "2025-10-05T01:08:17.976Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/6585fe6308b8f1ac0ac4d37ac69064ec2a36b81cf9080813cb666229694c/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0a8c36c37d8835420fc337be4aaa03c3a34272028919de575124c10d31a7e304", size = 2015005, upload-time = "2025-10-05T01:08:20.318Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/a9d0a577be157170f513e6ad6ebb3cd8dd9602c670d74911e9c5534e1c1d/zeroconf-0.148.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:848d57df1bb3b48279ba9b66e6c1f727570e2c8e7e0c4518c2daffaf23419d03", size = 2253785, upload-time = "2025-10-05T01:08:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/ae/43/6679c16d4e6897c9aa502ee35c122bb605eee855612fad2ef6e0e13722c4/zeroconf-0.148.0-cp312-cp312-win32.whl", hash = "sha256:ba6eaa6b769924391c213dc391f36bd1c7e3ebe45fa3fa0cd97451b4f9ccef5c", size = 1295810, upload-time = "2025-10-05T01:08:23.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/a2d61df82086ddd32b9a5870ac683e8e5038cae38e2433c4fa03fe044235/zeroconf-0.148.0-cp312-cp312-win_amd64.whl", hash = "sha256:cec84ae7028db4a3addcc18628d12456cf39a9e973abee4a41e3b94d0db7df4c", size = 1533317, upload-time = "2025-10-05T01:08:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/46/09/394a24a633645063557c5144c9abb694699df76155dcab5e1e3078dd1323/zeroconf-0.148.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ad889929bdc3953530546a4a2486d8c07f5a18d4ef494a98446bf17414897a7", size = 1714465, upload-time = "2025-10-05T01:08:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/f57c4bfcceb67fe474705cbadba3f8f7a88bdc95892e74ba6d85e24d28c3/zeroconf-0.148.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:29fb10be743650eb40863f1a1ee868df1869357a0c2ab75140ee3d7079540c1e", size = 1683877, upload-time = "2025-10-05T01:08:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/b3e2d39c40802a8cc9415357acdb76ff01bc29e25ffaa811771b6fffc428/zeroconf-0.148.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2995e74969c577461060539164c47e1ba674470585cb0f954ebeb77f032f3c2", size = 2122874, upload-time = "2025-10-05T01:08:32.11Z" }, + { url = "https://files.pythonhosted.org/packages/66/eb/0ac2bf51d58d47cfa854628036a7ad95544a1802bc890f3d69649dc35e46/zeroconf-0.148.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5be50346efdc20823f9d68d8757612767d11ceb8da7637d46080977b87912551", size = 1922164, upload-time = "2025-10-05T01:08:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/59/ff/c7372507c7e25ad3499fe08d4678deb1ed41c57f78ff5df43bd2d4d98cfc/zeroconf-0.148.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc88fd01b5552ffb4d5bc551d027ac28a1852c03ceab754d02bd0d5f04c54e85", size = 2214119, upload-time = "2025-10-05T01:08:35.478Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/57f0889f47923b4fa4364b62b7b3ffc347f6bad09a25ce4e578b8991a86d/zeroconf-0.148.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:5af260c74187751c0df6a40f38d6fd17cb8658a734b0e1148a86084b71c1977c", size = 2137609, upload-time = "2025-10-05T00:21:15.953Z" }, + { url = "https://files.pythonhosted.org/packages/3b/33/9cb5558695c1377941dbb10a5591f88a787f9e1fba130642693d5c80663b/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6078c73a76d49ba969ca2bb7067e4d58ebd2b79a5f956e45c4c989b11d36e03", size = 2154314, upload-time = "2025-10-05T01:08:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/38/06/cf4e17a86922b4561d85d36f50f1adada1328723e882d95aa42baefa5479/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3e686bf741158f4253d5e0aa6a8f9d34b3140bf5826c0aca9b906273b9c77a5f", size = 2004973, upload-time = "2025-10-05T01:08:39.825Z" }, + { url = "https://files.pythonhosted.org/packages/a4/61/937a405783317639cd11e7bfab3879669896297b6ca2edfb0d2d9c8dbb30/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:52d6ac06efe05a1e46089cfde066985782824f64b64c6982e8678e70b4b49453", size = 2237775, upload-time = "2025-10-05T01:08:41.535Z" }, + { url = "https://files.pythonhosted.org/packages/03/43/a1751c4b63e108a2318c2266e5afdd9d62292250aa8b1a8ed1674090885c/zeroconf-0.148.0-cp313-cp313-win32.whl", hash = "sha256:b9ba58e2bbb0cff020b54330916eaeb8ee8f4b0dde852e84f670f4ca3a0dd059", size = 1291073, upload-time = "2025-10-05T01:08:43.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/69/5f4f9eb14506e2afd2d423472e566d5455334d0c8740b933914d642bdbb5/zeroconf-0.148.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee3fcc2edcc04635cf673c400abac2f0c22c9786490fbfb971e0a860a872bf26", size = 1528568, upload-time = "2025-10-05T01:08:45.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, + { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" }, + { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" }, + { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" }, + { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" }, + { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" }, + { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/ff/81/1f838cd174ca2f4ab7dbf8b1792325505480fd8f9a5a47c6fb0b63d7b26b/zeroconf-0.148.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3a6e61c5b3905efed2137a07d84953ba4419795646fd18eccbd17018da2e965d", size = 1713675, upload-time = "2025-10-05T01:09:28.001Z" }, + { url = "https://files.pythonhosted.org/packages/86/db/0eb06b898d1ca8875039f661279d1806e078fe6ed52885f9fd6e15b8dd45/zeroconf-0.148.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae41805df91ff657dd70179089df1d03e7ab756feb13dbcbc8a412cd8c50623e", size = 1688019, upload-time = "2025-10-05T01:09:30.466Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5a69dd5753c93ab71a8f5b2f6294badaeb9629a15483073a37f8bd6fb3f2/zeroconf-0.148.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff53a8d01c3b9a1e50606446ed07d534db5def55046ffdbbacac7888d9c699ae", size = 2199054, upload-time = "2025-10-05T01:09:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/93/96/e438063fdcdd3b0f1be68b7d8b203b8fda9fd982e2990b2a411bd7c9ad94/zeroconf-0.148.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:45a51e1f507dfc3f621ecc23168aaa56783b33d4f5d676088f69f913f0b56073", size = 1972957, upload-time = "2025-10-05T01:09:34.681Z" }, + { url = "https://files.pythonhosted.org/packages/68/87/218afba8263af3629d2c85d25473c117b343dc403b3c57b1dd4834b51ea5/zeroconf-0.148.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b41d1004e0356720ac81cddd7e4bd622c73be951b92c6b89ccaf6429996563ac", size = 2274996, upload-time = "2025-10-05T01:09:37.081Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/617700386783ba2e77dc94da0d3d05c5ce21f5a9944bad7355b39a74f024/zeroconf-0.148.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a53293291d683fc690c1cee0352f2c6dfc0f717f643e676a3c6f0df37a7f1b17", size = 2228791, upload-time = "2025-10-05T01:09:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/76/75/6c27281bbcb00d5c2d119ab2ed7cc8ab2ef813127a9afcc55d5050b68f2e/zeroconf-0.148.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:04607192ef33f4c9280bbd1b716564f821a7935661b8a35be34ee1e0acc0657d", size = 2023056, upload-time = "2025-10-05T01:09:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/b191110946bd59a999bc1aee2613b1669c1d1dbf161b0d182a56abfdbf6e/zeroconf-0.148.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2545561551a9ba684897785e6678f3c67fee8e13b09f9f88d0f69e570b540d5b", size = 2299045, upload-time = "2025-10-05T01:09:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/51/51/ada823f1515da3abbd24d136879980da890fad4508f17ee2b0238bff948e/zeroconf-0.148.0-cp39-cp39-win32.whl", hash = "sha256:d78e200a3830074c79c0a014595ace49a24afa6a8a2d903326f44751107afbfd", size = 1315239, upload-time = "2025-10-05T01:09:45.213Z" }, + { url = "https://files.pythonhosted.org/packages/6f/25/8d8a05b445adb3e7302c8f74f2f8f0a1e6b30a134a25ca0eb38af6c8db24/zeroconf-0.148.0-cp39-cp39-win_amd64.whl", hash = "sha256:0800443953f9b490ded275a84008631f441879e9164635a62a4f1c6e71f28bd0", size = 1532475, upload-time = "2025-10-05T01:09:47.516Z" }, +] From 4bca972afc9a422698e22f314787722018e233e8 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 14:29:59 -0700 Subject: [PATCH 12/45] examples: iPhone + Mac webcam dual recording recipe First SDK example under examples/. Shows the shortest end-to-end SyncField setup: one SessionOrchestrator, two UVCWebcamStream adapters (Mac built-in webcam + iPhone over Continuity Camera, both driven through cv2.VideoCapture), one viewer launch. Each example lives in its own subdirectory with a runnable record.py and a self-contained README.md covering hardware checklist, install, run, output layout, architecture diagram, and troubleshooting table. New recipes scale by copying the pattern, not by sharing helpers. record.py extras: - --probe mode: enumerates openable OpenCV device indices with their geometry, so users can figure out which index is which camera before starting a session. - Graceful ImportError messages pointing at the right `pip install` extras when viewer / uvc are missing. Top-level examples/README.md is the catalog index and documents the "one directory per recipe, one record.py per directory" convention for future contributions (oak_plus_webcam, iphone_imu, multi_host_pair, tactile_rig, ...). Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/README.md | 58 ++++++ examples/iphone_mac_webcam/README.md | 142 ++++++++++++++ examples/iphone_mac_webcam/record.py | 274 +++++++++++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/iphone_mac_webcam/README.md create mode 100644 examples/iphone_mac_webcam/record.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..15781e8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,58 @@ +# SyncField SDK Examples + +Runnable end-to-end recipes showing real SDK setups. Each subdirectory is a self-contained example with a `README.md` explaining the hardware + setup, a `record.py` script you can run directly, and a description of what output it produces. + +Start with the simplest example that matches hardware you actually have, then scale up by swapping or adding one adapter at a time. + +## Catalog + +| Example | Hardware | What it shows | +|---|---|---| +| [`iphone_mac_webcam/`](./iphone_mac_webcam/) | Mac built-in webcam + iPhone (Continuity Camera) | Shortest end-to-end recipe: two OpenCV video streams through `UVCWebcamStream`, live preview in the desktop viewer, MP4 + timestamps written to disk | + +More recipes will be added as the rigs they target come online. Expected next: + +- **`oak_plus_webcam/`** — OAK-D Pro depth camera + Mac webcam (add depth to the dual-camera setup) +- **`iphone_imu/`** — iPhone + BLE IMU (`BLEImuGenericStream`) showing mixed video + sensor streams +- **`tactile_rig/`** — webcam + tactile sensor via `OgloTactileStream` showing custom-adapter integration +- **`multi_host_pair/`** — two Macs on the same WiFi recording together with `LeaderRole` / `FollowerRole` + +## How to run any example + +Every example follows the same shape: + +```bash +cd examples/ +pip install "syncfield[uvc,audio,viewer]" # extras vary — see the example's README +python record.py # blocking, opens the viewer +``` + +Inside the viewer, click **Record** to start the session, **Stop** to finish, and close the window to exit. Output files land in `./output/` by default; every example accepts `--output-dir` if you want a different location. + +## Architecture shared by every example + +Whatever the hardware, every recipe builds the same three-step pipeline: + +``` +1. Construct one SessionOrchestrator + ↓ +2. Register one Stream per capture source (session.add(...)) + ↓ +3. Launch the viewer — it drives start() / stop() from the UI buttons +``` + +Swapping or adding streams is always a one-line change, which is the whole point of the `Stream` SPI — the orchestrator doesn't know or care whether a stream is a webcam, a depth camera, or a tactile sensor. + +See the [**Python SDK docs**](https://opengraphlabs.com/sdk/python) for the full API reference and the [**Concepts**](https://opengraphlabs.com/concepts) page for how these recipes fit into the larger capture-then-sync workflow. + +## Adding your own example + +New examples go into their own subdirectory: + +``` +examples/your_recipe/ +├── README.md # Hardware checklist, install, run, output, troubleshooting +└── record.py # One runnable script — keep it under ~200 lines +``` + +Keep each `record.py` self-contained (no shared helpers across examples) so readers can copy-paste one file and have it work. Prefer clarity over cleverness: comments that explain *why*, not *what*. diff --git a/examples/iphone_mac_webcam/README.md b/examples/iphone_mac_webcam/README.md new file mode 100644 index 0000000..942574b --- /dev/null +++ b/examples/iphone_mac_webcam/README.md @@ -0,0 +1,142 @@ +# iPhone + Mac Webcam + +**Shortest end-to-end SyncField recipe.** Two OpenCV-based video streams — the Mac's built-in webcam and an iPhone over Continuity Camera — captured through the desktop viewer and saved to disk. + +Use this as the template for your own multi-camera rig. Adding more streams later (OAK-D, BLE IMU, tactile, ...) is a one-line `session.add(...)` change. + +## What you'll see + +When you run `record.py`, the SyncField desktop viewer pops up with: + +- Two stream cards (`mac_webcam`, `iphone`) showing live previews +- A session clock panel +- A big red **Record** button — click it to start the session +- A **Stop** button — click it when you're done +- A running table of any health events either camera emits + +Close the viewer window to exit. The output files are on disk regardless of whether you pressed Stop (crash-safe design). + +## Hardware checklist + +- [x] **Mac with a working webcam** — built-in FaceTime camera or any USB webcam at index 0 +- [x] **iPhone** signed in to the same Apple ID as the Mac +- [x] **Continuity Camera enabled** — `System Settings → General → AirPlay & Handoff → Continuity Camera: ON` +- [x] **iPhone within Bluetooth range** of the Mac +- [x] Ideally **both devices on wall power** — Continuity can drop mid-session on battery + +## Install + +```bash +pip install "syncfield[uvc,audio,viewer]" +``` + +| Extra | What it's for | +|---|---| +| `uvc` | OpenCV — the `UVCWebcamStream` adapter that drives both cameras | +| `audio` | `sounddevice` — needed by the sync tone / chirp path, even though chirps are skipped in single-host mode | +| `viewer` | `dearpygui` + `numpy` — the bundled desktop viewer | + +## Run + +```bash +# Default: webcam at index 0, iPhone at index 1 +python record.py + +# Custom indices, output dir, geometry +python record.py \ + --webcam-index 0 \ + --iphone-index 1 \ + --output-dir ./my_recording \ + --width 1920 --height 1080 --fps 30 +``` + +### Not sure which index is which? + +Run the probe first — it prints every openable OpenCV device with its current geometry without starting a recording session: + +```bash +python record.py --probe +``` + +``` +Probing OpenCV device indices 0..4 ... + + [0] OK 1280x720 @ 30 fps + [1] OK 1920x1080 @ 30 fps + [2] not available + [3] not available + [4] not available + +Pick the index that matches your Mac webcam and iPhone, then rerun without --probe. +``` + +If the iPhone doesn't appear, wake it and hold it near the Mac — Continuity Camera activates on-demand. + +## Output + +``` +output/ +├── mac_webcam.mp4 # Mac built-in webcam video +├── mac_webcam.timestamps.jsonl # Per-frame capture timestamps +├── iphone.mp4 # iPhone Continuity camera video +├── iphone.timestamps.jsonl +├── sync_point.json # Session anchor + chirp metadata +├── manifest.json # Stream capabilities + file paths +└── session_log.jsonl # Crash-safe timeline log +``` + +The `*.timestamps.jsonl` files plus `sync_point.json` are what the SyncField sync service consumes for post-hoc frame-level alignment. The MP4s are the actual video recordings. + +## Architecture at a glance + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SessionOrchestrator (host_id = mac_studio) │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ UVCWebcamStream │ │ UVCWebcamStream │ │ +│ │ id = "mac_webcam" │ │ id = "iphone" │ │ +│ │ device_index = 0 │ │ device_index = 1 │ │ +│ │ │ │ │ │ +│ │ cv2.VideoCapture │ │ cv2.VideoCapture │ │ +│ │ → MP4 writer │ │ → MP4 writer │ │ +│ │ → timestamps JSONL │ │ → timestamps JSONL │ │ +│ │ → latest_frame │ │ → latest_frame │ │ +│ └──────────┬───────────┘ └──────────┬───────────┘ │ +└─────────────│─────────────────────────────│────────────────┘ + │ │ + ▼ ▼ + viewer stream card viewer stream card + (live preview) (live preview) +``` + +One `SessionOrchestrator`, two `UVCWebcamStream` adapters, one viewer. Every addition to a multi-device rig is just one more `session.add(...)` call. + +## Single-host? Multi-host? + +This example is **single-host** — one Mac running both cameras. SyncField's multi-host coordination (mDNS discovery, leader/follower roles, chirp-anchored alignment across machines) is handled by a separate set of examples in `examples/multi_host_*`. Both modes share the same `SessionOrchestrator` API — the difference is just which `role` you pass. + +The orchestrator is configured with `SyncToneConfig.default()` (chirp enabled), but the chirp is **automatically skipped** here because neither OpenCV camera declares an audio track. You'll see an INFO log line explaining this. The chirp starts firing as soon as you register any stream with `capabilities.provides_audio_track=True` (e.g., an audio-capable OAK camera, a microphone stream, or once you move to multi-host mode). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Viewer opens but both previews are black | Wrong device indices | Run `python record.py --probe` and pass the correct indices | +| `cv2.VideoCapture(1)` fails | iPhone not connected to Continuity Camera | Wake the iPhone, hold it near the Mac, check System Settings | +| iPhone video is 720p instead of 1080p | macOS picked a lower-res stream profile | Try different `--width`/`--height` values; some iPhones cap Continuity at 1280×720 | +| `ImportError: syncfield.viewer requires the 'viewer' extra` | Missing dev dep | `pip install "syncfield[viewer]"` | +| `ImportError: UVCWebcamStream requires opencv-python` | Missing UVC extra | `pip install "syncfield[uvc]"` | +| Recording stops after a few seconds | Continuity Camera dropped | Plug the iPhone into power; keep it awake; minimize Bluetooth contention | +| Frame rate lower than requested | USB bandwidth contention | Lower `--fps`, or plug the webcam into a dedicated USB bus | + +## Next steps + +Once this recipe works, try: + +1. **Add a JSONL sensor log** — register a `JSONLFileStream` alongside the two cameras so sensor data gets the same timestamp treatment. See the `JSONLFileStream` adapter docs. +2. **Swap one camera for an OAK-D** — use `OakCameraStream` to add depth capture. +3. **Go multi-host** — split the two cameras across two Macs and wire them up with `LeaderRole` / `FollowerRole`. See the `sdk/multi-host` docs page. +4. **Add a BLE IMU** — `BLEImuGenericStream` registers like any other stream. + +Each is a one-file change on top of this script. diff --git a/examples/iphone_mac_webcam/record.py b/examples/iphone_mac_webcam/record.py new file mode 100644 index 0000000..77bcb06 --- /dev/null +++ b/examples/iphone_mac_webcam/record.py @@ -0,0 +1,274 @@ +"""iPhone + Mac Webcam — dual OpenCV recording through SyncField. + +Records two video streams at once — the Mac's built-in webcam and an +iPhone connected over Continuity Camera — and opens the SyncField +desktop viewer so you can click Record, watch both previews, and +click Stop. Both cameras are captured through the same +:class:`~syncfield.adapters.UVCWebcamStream` adapter because macOS +exposes the iPhone as an ordinary UVC device once Continuity Camera +is active. + +Why this example exists +----------------------- +It's the shortest end-to-end SyncField recipe: one SessionOrchestrator, +two off-the-shelf adapters, one viewer launch. Use it as the template +for your own multi-camera rig and gradually swap / add streams as you +scale up (OAK-D, BLE IMU, tactile, multi-host ...). + +Hardware checklist +------------------ +1. Mac with a working built-in webcam (or any USB webcam at index 0). +2. iPhone signed in to the same Apple ID as the Mac, Bluetooth on, + Continuity Camera enabled (``System Settings → General → AirPlay + & Handoff → Continuity Camera``). +3. iPhone within Bluetooth range of the Mac. +4. Both devices ideally on wall power — Continuity occasionally + disconnects mid-session on battery. + +Install +------- +:: + + pip install "syncfield[uvc,audio,viewer]" + +Run +--- +:: + + # Default: webcam at index 0, iPhone at index 1 + python record.py + + # Custom indices / output dir / geometry + python record.py --webcam-index 0 --iphone-index 1 \\ + --output-dir ./my_recording \\ + --width 1920 --height 1080 --fps 30 + + # Sanity-check which OpenCV index is which camera (before running) + python record.py --probe + +Output +------ +After you click **Record** then **Stop** in the viewer, the output +directory looks like:: + + output/ + ├── mac_webcam.mp4 # Mac built-in webcam video + ├── mac_webcam.timestamps.jsonl # Per-frame capture timestamps + ├── iphone.mp4 # iPhone Continuity camera video + ├── iphone.timestamps.jsonl + ├── sync_point.json # Session anchor + chirp info + ├── manifest.json # Per-stream metadata + └── session_log.jsonl # Crash-safe timeline log + +The two ``*.timestamps.jsonl`` files and ``sync_point.json`` are the +artifacts the SyncField sync service consumes for post-hoc frame-level +alignment across the two cameras. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import syncfield as sf +from syncfield.adapters import UVCWebcamStream + + +def build_session( + *, + output_dir: Path, + webcam_index: int, + iphone_index: int, + width: int, + height: int, + fps: float, +) -> sf.SessionOrchestrator: + """Construct a :class:`~syncfield.SessionOrchestrator` with two cameras. + + Both cameras use the same :class:`UVCWebcamStream` adapter because + macOS surfaces the iPhone Continuity Camera as a standard UVC + device. The only thing that differs is the ``device_index`` — on + most Macs the built-in webcam lands at ``0`` and the iPhone at + ``1`` once Continuity is active. Use ``--probe`` to verify. + + The chirp is enabled on the orchestrator but **will be skipped** + for this setup because neither OpenCV camera declares an audio + track (``provides_audio_track=False``). That's fine for a + single-host example; the chirp is a multi-host acoustic anchor + and isn't needed when only one host is recording. When you later + add a host with a microphone (e.g. by registering a separate + audio stream or moving to the multi-host examples), the chirp + will start playing automatically. + """ + session = sf.SessionOrchestrator( + host_id="mac_studio", + output_dir=output_dir, + sync_tone=sf.SyncToneConfig.default(), # enabled, auto-skipped here + ) + + # Mac built-in webcam (or any USB webcam at the specified index). + session.add( + UVCWebcamStream( + id="mac_webcam", + device_index=webcam_index, + output_dir=output_dir, + width=width, + height=height, + fps=fps, + ) + ) + + # iPhone via Continuity Camera — treated exactly like a UVC webcam. + session.add( + UVCWebcamStream( + id="iphone", + device_index=iphone_index, + output_dir=output_dir, + width=width, + height=height, + fps=fps, + ) + ) + + return session + + +def probe_camera_indices(max_index: int = 5) -> None: + """Print which OpenCV device indices are currently openable. + + Run with ``--probe`` before your first recording to confirm which + index is the built-in webcam and which is the iPhone. The iPhone + only shows up after Continuity Camera is active (wake the phone + and hold it near the Mac if it doesn't appear). + """ + try: + import cv2 + except ImportError: + print( + "opencv-python is not installed. Run:\n" + " pip install 'syncfield[uvc]'", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Probing OpenCV device indices 0..{max_index - 1} ...\n") + for idx in range(max_index): + cap = cv2.VideoCapture(idx) + if not cap.isOpened(): + print(f" [{idx}] not available") + continue + ok, _ = cap.read() + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) + cap.release() + status = "OK" if ok else "open but read() failed" + print(f" [{idx}] {status} {width}x{height} @ {fps:.0f} fps") + print( + "\nPick the index that matches your Mac webcam and iPhone," + " then rerun without --probe." + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Record Mac built-in webcam + iPhone (Continuity) through" + " the SyncField SDK, with the desktop viewer." + ), + ) + parser.add_argument( + "--webcam-index", + type=int, + default=0, + help="OpenCV device index for the Mac built-in webcam (default: 0).", + ) + parser.add_argument( + "--iphone-index", + type=int, + default=1, + help=( + "OpenCV device index for the iPhone Continuity Camera" + " (default: 1)." + ), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("./output"), + help="Directory where video + session artifacts are written.", + ) + parser.add_argument( + "--width", + type=int, + default=1920, + help="Requested frame width in pixels (default: 1920).", + ) + parser.add_argument( + "--height", + type=int, + default=1080, + help="Requested frame height in pixels (default: 1080).", + ) + parser.add_argument( + "--fps", + type=float, + default=30.0, + help="Requested frame rate in Hz (default: 30).", + ) + parser.add_argument( + "--probe", + action="store_true", + help=( + "Don't record — just probe which OpenCV device indices" + " are currently openable and print their geometry." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.probe: + probe_camera_indices() + return 0 + + args.output_dir.mkdir(parents=True, exist_ok=True) + + session = build_session( + output_dir=args.output_dir, + webcam_index=args.webcam_index, + iphone_index=args.iphone_index, + width=args.width, + height=args.height, + fps=args.fps, + ) + + print(f"Session built. Output directory: {args.output_dir.resolve()}") + print( + "Opening the SyncField desktop viewer. Click Record to start," + " Stop when done. Close the window to finalize." + ) + + # Blocking viewer launch. The viewer runs its own event loop and + # drives session.start() / session.stop() on worker threads when + # the user clicks the Record / Stop buttons. `launch()` returns + # when the user closes the viewer window. + try: + import syncfield.viewer + except ImportError: + print( + "\nsyncfield.viewer is not installed. Run:\n" + " pip install 'syncfield[viewer]'\n", + file=sys.stderr, + ) + return 1 + + syncfield.viewer.launch(session) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1289f53e73d6637e6652008d568e7dafa43b36e3 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 14:32:38 -0700 Subject: [PATCH 13/45] examples(iphone_mac_webcam): shrink record.py to the core 3 calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip the example down to ~35 lines so the three key interfaces — SessionOrchestrator, session.add(UVCWebcamStream), viewer.launch — are the most visible things on the screen. Remove the probe helper, the build_session factory, the verbose argparse descriptions, the ImportError guidance, and every explanatory comment that repeated what the code already said. The README next door keeps the hardware checklist, --probe instructions, output layout, and troubleshooting table. record.py is meant to be skimmable as "oh, it's literally three calls." Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/iphone_mac_webcam/record.py | 265 ++------------------------- 1 file changed, 13 insertions(+), 252 deletions(-) diff --git a/examples/iphone_mac_webcam/record.py b/examples/iphone_mac_webcam/record.py index 77bcb06..4037719 100644 --- a/examples/iphone_mac_webcam/record.py +++ b/examples/iphone_mac_webcam/record.py @@ -1,274 +1,35 @@ -"""iPhone + Mac Webcam — dual OpenCV recording through SyncField. +"""Record Mac webcam + iPhone (Continuity Camera) through SyncField. -Records two video streams at once — the Mac's built-in webcam and an -iPhone connected over Continuity Camera — and opens the SyncField -desktop viewer so you can click Record, watch both previews, and -click Stop. Both cameras are captured through the same -:class:`~syncfield.adapters.UVCWebcamStream` adapter because macOS -exposes the iPhone as an ordinary UVC device once Continuity Camera -is active. - -Why this example exists ------------------------ -It's the shortest end-to-end SyncField recipe: one SessionOrchestrator, -two off-the-shelf adapters, one viewer launch. Use it as the template -for your own multi-camera rig and gradually swap / add streams as you -scale up (OAK-D, BLE IMU, tactile, multi-host ...). - -Hardware checklist ------------------- -1. Mac with a working built-in webcam (or any USB webcam at index 0). -2. iPhone signed in to the same Apple ID as the Mac, Bluetooth on, - Continuity Camera enabled (``System Settings → General → AirPlay - & Handoff → Continuity Camera``). -3. iPhone within Bluetooth range of the Mac. -4. Both devices ideally on wall power — Continuity occasionally - disconnects mid-session on battery. - -Install -------- -:: - - pip install "syncfield[uvc,audio,viewer]" - -Run ---- -:: - - # Default: webcam at index 0, iPhone at index 1 + pip install "syncfield[uvc,viewer]" python record.py - - # Custom indices / output dir / geometry - python record.py --webcam-index 0 --iphone-index 1 \\ - --output-dir ./my_recording \\ - --width 1920 --height 1080 --fps 30 - - # Sanity-check which OpenCV index is which camera (before running) - python record.py --probe - -Output ------- -After you click **Record** then **Stop** in the viewer, the output -directory looks like:: - - output/ - ├── mac_webcam.mp4 # Mac built-in webcam video - ├── mac_webcam.timestamps.jsonl # Per-frame capture timestamps - ├── iphone.mp4 # iPhone Continuity camera video - ├── iphone.timestamps.jsonl - ├── sync_point.json # Session anchor + chirp info - ├── manifest.json # Per-stream metadata - └── session_log.jsonl # Crash-safe timeline log - -The two ``*.timestamps.jsonl`` files and ``sync_point.json`` are the -artifacts the SyncField sync service consumes for post-hoc frame-level -alignment across the two cameras. """ -from __future__ import annotations - import argparse -import sys from pathlib import Path import syncfield as sf +import syncfield.viewer from syncfield.adapters import UVCWebcamStream -def build_session( - *, - output_dir: Path, - webcam_index: int, - iphone_index: int, - width: int, - height: int, - fps: float, -) -> sf.SessionOrchestrator: - """Construct a :class:`~syncfield.SessionOrchestrator` with two cameras. +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--webcam-index", type=int, default=0) + parser.add_argument("--iphone-index", type=int, default=1) + parser.add_argument("--output-dir", type=Path, default=Path("./output")) + args = parser.parse_args() - Both cameras use the same :class:`UVCWebcamStream` adapter because - macOS surfaces the iPhone Continuity Camera as a standard UVC - device. The only thing that differs is the ``device_index`` — on - most Macs the built-in webcam lands at ``0`` and the iPhone at - ``1`` once Continuity is active. Use ``--probe`` to verify. + args.output_dir.mkdir(parents=True, exist_ok=True) - The chirp is enabled on the orchestrator but **will be skipped** - for this setup because neither OpenCV camera declares an audio - track (``provides_audio_track=False``). That's fine for a - single-host example; the chirp is a multi-host acoustic anchor - and isn't needed when only one host is recording. When you later - add a host with a microphone (e.g. by registering a separate - audio stream or moving to the multi-host examples), the chirp - will start playing automatically. - """ session = sf.SessionOrchestrator( host_id="mac_studio", - output_dir=output_dir, - sync_tone=sf.SyncToneConfig.default(), # enabled, auto-skipped here - ) - - # Mac built-in webcam (or any USB webcam at the specified index). - session.add( - UVCWebcamStream( - id="mac_webcam", - device_index=webcam_index, - output_dir=output_dir, - width=width, - height=height, - fps=fps, - ) - ) - - # iPhone via Continuity Camera — treated exactly like a UVC webcam. - session.add( - UVCWebcamStream( - id="iphone", - device_index=iphone_index, - output_dir=output_dir, - width=width, - height=height, - fps=fps, - ) - ) - - return session - - -def probe_camera_indices(max_index: int = 5) -> None: - """Print which OpenCV device indices are currently openable. - - Run with ``--probe`` before your first recording to confirm which - index is the built-in webcam and which is the iPhone. The iPhone - only shows up after Continuity Camera is active (wake the phone - and hold it near the Mac if it doesn't appear). - """ - try: - import cv2 - except ImportError: - print( - "opencv-python is not installed. Run:\n" - " pip install 'syncfield[uvc]'", - file=sys.stderr, - ) - sys.exit(1) - - print(f"Probing OpenCV device indices 0..{max_index - 1} ...\n") - for idx in range(max_index): - cap = cv2.VideoCapture(idx) - if not cap.isOpened(): - print(f" [{idx}] not available") - continue - ok, _ = cap.read() - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - fps = cap.get(cv2.CAP_PROP_FPS) - cap.release() - status = "OK" if ok else "open but read() failed" - print(f" [{idx}] {status} {width}x{height} @ {fps:.0f} fps") - print( - "\nPick the index that matches your Mac webcam and iPhone," - " then rerun without --probe." - ) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Record Mac built-in webcam + iPhone (Continuity) through" - " the SyncField SDK, with the desktop viewer." - ), - ) - parser.add_argument( - "--webcam-index", - type=int, - default=0, - help="OpenCV device index for the Mac built-in webcam (default: 0).", - ) - parser.add_argument( - "--iphone-index", - type=int, - default=1, - help=( - "OpenCV device index for the iPhone Continuity Camera" - " (default: 1)." - ), - ) - parser.add_argument( - "--output-dir", - type=Path, - default=Path("./output"), - help="Directory where video + session artifacts are written.", - ) - parser.add_argument( - "--width", - type=int, - default=1920, - help="Requested frame width in pixels (default: 1920).", - ) - parser.add_argument( - "--height", - type=int, - default=1080, - help="Requested frame height in pixels (default: 1080).", - ) - parser.add_argument( - "--fps", - type=float, - default=30.0, - help="Requested frame rate in Hz (default: 30).", - ) - parser.add_argument( - "--probe", - action="store_true", - help=( - "Don't record — just probe which OpenCV device indices" - " are currently openable and print their geometry." - ), - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - - if args.probe: - probe_camera_indices() - return 0 - - args.output_dir.mkdir(parents=True, exist_ok=True) - - session = build_session( output_dir=args.output_dir, - webcam_index=args.webcam_index, - iphone_index=args.iphone_index, - width=args.width, - height=args.height, - fps=args.fps, ) - - print(f"Session built. Output directory: {args.output_dir.resolve()}") - print( - "Opening the SyncField desktop viewer. Click Record to start," - " Stop when done. Close the window to finalize." - ) - - # Blocking viewer launch. The viewer runs its own event loop and - # drives session.start() / session.stop() on worker threads when - # the user clicks the Record / Stop buttons. `launch()` returns - # when the user closes the viewer window. - try: - import syncfield.viewer - except ImportError: - print( - "\nsyncfield.viewer is not installed. Run:\n" - " pip install 'syncfield[viewer]'\n", - file=sys.stderr, - ) - return 1 + session.add(UVCWebcamStream("mac_webcam", args.webcam_index, args.output_dir)) + session.add(UVCWebcamStream("iphone", args.iphone_index, args.output_dir)) syncfield.viewer.launch(session) - return 0 if __name__ == "__main__": - raise SystemExit(main()) + main() From 7aa3918ca079d6c3fc9424a1f8514734ec3d8367 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 14:57:27 -0700 Subject: [PATCH 14/45] feat(streams): physical device dedup + SessionOrchestrator.remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stream-management details that surfaced once users started mixing code-registered streams with the viewer's Discover modal: 1. Two cards for the same physical webcam User registers UVCWebcamStream("mac_webcam", 0) in code, then clicks Discover devices in the viewer, which happily constructs a second UVCWebcamStream at index 0 under a different id ("macbook_pro"). Session ends up with 4 streams for 2 cameras. Fix: every Stream now exposes an optional `device_key` property returning a (adapter_type, device_id) tuple that names the physical hardware it owns. None means "no hardware identity", which keeps stream-id as the only uniqueness check for streams like JSONLFileStream. - StreamBase.device_key → None (default) - UVCWebcamStream.device_key → ("uvc_webcam", str(device_index)) - SessionOrchestrator.add() rejects a new stream whose device_key matches any already-registered stream, with a clear ValueError naming both the new device_key and the existing stream id. - scan_and_add() skips discovered devices whose (adapter_type, device_id) is already claimed on the session. - Discovery modal grays out the checkbox for already-owned devices, labels them "✓ Already added as ''", and excludes them from the default selection set so the common "click Add" flow doesn't even try to re-register them. - Defense-in-depth skip in DiscoveryModal._on_add_click catches stale selection snapshots where something got registered between scan and the Add button. 2. No way to remove a stream you no longer want Added SessionOrchestrator.remove(stream_id): - Valid in IDLE, CONNECTED, and STOPPED. - Refuses in CONNECTING / PREPARING / COUNTDOWN / RECORDING / STOPPING — removing a stream mid-lifecycle would leave partial artifacts on disk. - If CONNECTED, calls stream.disconnect() before unregistering so hardware handles are released. - Frees the stream's device_key so a fresh stream can re-claim the same physical device afterwards. Viewer integration: - StreamCard header gets a right-aligned × button that fires an on_remove(stream_id) callback. - ViewerLayout injects the callback at card creation; it dispatches SessionOrchestrator.remove on a worker thread so device teardown never blocks the DPG render thread. - Card.update() takes session_state and enables/disables the button based on the same IDLE/CONNECTED/STOPPED predicate the orchestrator enforces. Cached so DPG doesn't receive a fresh configure_item call every frame. - The existing _update_streams path already deletes DPG cards for ids that disappear from the SessionSnapshot, so removal flows through the next poller tick with no extra plumbing. Tests (+8 on top of the 340 baseline → 348 passing): - TestDeviceKey: default None, override returns stable tuple. - TestAdd: rejects duplicate device_key, different device_keys add cleanly, None device_keys fall back to id-only uniqueness. - TestRemove: remove in IDLE, unknown id raises KeyError, rejected during RECORDING, allowed from STOPPED, frees device_key for re-add. Does NOT touch parallel discovery/viewer work (theme.py, app.py, demo.py, types.py state-machine additions, _ble.py) still in flight on the co-worker's branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/uvc_webcam.py | 9 +- src/syncfield/discovery/scanner.py | 15 + src/syncfield/orchestrator.py | 541 +++++++++++++++--- src/syncfield/stream.py | 219 ++++++- .../viewer/widgets/discovery_modal.py | 57 +- src/syncfield/viewer/widgets/layout.py | 502 +++++++++++++--- src/syncfield/viewer/widgets/stream_card.py | 143 ++++- tests/unit/test_orchestrator.py | 222 ++++++- tests/unit/test_stream.py | 29 + 9 files changed, 1529 insertions(+), 208 deletions(-) diff --git a/src/syncfield/adapters/uvc_webcam.py b/src/syncfield/adapters/uvc_webcam.py index 74d20b3..ccd9278 100644 --- a/src/syncfield/adapters/uvc_webcam.py +++ b/src/syncfield/adapters/uvc_webcam.py @@ -26,7 +26,7 @@ ) from exc from syncfield.clock import SessionClock -from syncfield.stream import StreamBase +from syncfield.stream import DeviceKey, StreamBase from syncfield.types import ( FinalizationReport, SampleEvent, @@ -91,6 +91,13 @@ def __init__( self._frame_lock = threading.Lock() self._latest_frame: Any = None + @property + def device_key(self) -> Optional[DeviceKey]: + """``("uvc_webcam", str(device_index))`` — the OpenCV index is + the stable hardware id on macOS / Linux / Windows. + """ + return ("uvc_webcam", str(self._device_index)) + # ------------------------------------------------------------------ # Stream SPI # ------------------------------------------------------------------ diff --git a/src/syncfield/discovery/scanner.py b/src/syncfield/discovery/scanner.py index ca39575..4038033 100644 --- a/src/syncfield/discovery/scanner.py +++ b/src/syncfield/discovery/scanner.py @@ -290,6 +290,15 @@ def scan_and_add( # iteration surface for registered streams yet. existing_ids = set(session._streams.keys()) # noqa: SLF001 + # Snapshot the physical devices this session already owns so we + # don't double-add the same webcam / BLE peripheral / OAK when the + # user registered one in code and then ran scan_and_add too. + existing_device_keys: set[tuple[str, str]] = set() + for stream in session._streams.values(): # noqa: SLF001 + key = getattr(stream, "device_key", None) + if key is not None: + existing_device_keys.add(key) + added: List[DiscoveredDevice] = [] for device in report.devices: @@ -306,6 +315,12 @@ def scan_and_add( device.display_name, ) continue + if (device.adapter_type, device.device_id) in existing_device_keys: + logger.info( + "skipping %s: physical device already registered on this session", + device.display_name, + ) + continue try: stream_id = make_stream_id( diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 6c791a8..eea0c7e 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -6,20 +6,68 @@ coordination happens at the sync core when outputs from multiple hosts are submitted together. +Lifecycle +--------- + +SyncField 0.2 follows the same 4-phase lifecycle used by the egonaut +lab recorder:: + + ┌─────────┐ connect() ┌───────────┐ start() ┌──────────┐ + │ IDLE │─────────────▶│ CONNECTED │───────────▶│ COUNTDOWN│ + │ │◀─────────────│ │ └────┬─────┘ + └─────────┘ disconnect() └───────────┘ │ 3/2/1 + ▲ ▼ + │ ┌──────────────────┐ + stop()│ │ RECORDING │ + │ │ (streams writing)│ + │ └────────┬─────────┘ + │ │ stop() + │ ▼ + │ ┌──────────────────┐ + │ │ STOPPING │ + │ │ (chirp + finalize│ + │ └────────┬─────────┘ + └──────────────────────┘ + +* **Connect** opens device I/O on every stream so the viewer can + render live preview data. No file is written. +* **Countdown** is a short visual 3/2/1 so the operator has a beat to + glance at the rig before capture starts. +* **Start** atomically enables file writing on every stream, **then** + plays the start chirp so the chirp lands inside the recorded audio. +* **Stop** plays the stop chirp **first** (so it also lands in audio), + waits for the tail to flush, then tells every stream to stop writing. + The devices stay connected — the operator can immediately start + another recording without re-opening hardware. + +Legacy compatibility +-------------------- + +Applications that used the 0.1 one-shot API (``session.start()`` → +``session.stop()``) continue to work. When ``start()`` is called from +``IDLE`` the orchestrator auto-connects, runs the countdown, starts +recording, and plays the chirp; ``stop()`` from that auto-connected +mode tears everything down and lands in ``STOPPED``. + +Thread safety +------------- + +``add()`` is **not** thread-safe — call it from the thread that +constructed the session. ``connect()`` / ``start()`` / ``stop()`` / +``disconnect()`` acquire an internal reentrant lock, so it is safe for +other threads to observe state but only one lifecycle transition runs +at a time. + The file is organized top-down so the public lifecycle is easy to read: 1. Construction and public properties 2. ``add()`` — stream registration -3. ``start()`` — atomic multi-stream start with rollback -4. ``stop()`` — chirp + finalization + artifact persistence -5. Session log helpers (crash safety) -6. Chirp injection helpers - -Thread safety: - ``add()`` is **not** thread-safe — call it from the thread that - constructed the session. ``start()`` and ``stop()`` acquire an - internal reentrant lock, so it is safe for other threads to observe - state but only one lifecycle transition runs at a time. +3. ``connect()`` — open device I/O for live preview +4. ``start()`` — countdown then atomic multi-stream record-start with rollback +5. ``stop()`` — chirp + finalization + return to CONNECTED +6. ``disconnect()`` — tear down device I/O +7. Session log helpers (crash safety) +8. Chirp injection helpers """ from __future__ import annotations @@ -29,7 +77,7 @@ import time from importlib.metadata import version as _pkg_version from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union from syncfield.clock import SessionClock from syncfield.multihost.advertiser import SessionAdvertiser @@ -54,6 +102,76 @@ Role = Union[LeaderRole, FollowerRole] +# --------------------------------------------------------------------------- +# Module-level helpers used by SessionOrchestrator.start() / stop() / connect() +# --------------------------------------------------------------------------- + + +def _run_countdown( + countdown_s: float, + on_tick: Optional[Callable[[int], None]], +) -> None: + """Block the calling thread for ``countdown_s`` seconds, ticking. + + Fires ``on_tick(n)`` once per remaining whole second in descending + order (``3 → 2 → 1`` for ``countdown_s == 3``). The viewer uses + this callback to render a big overlay countdown on the session + clock panel. When ``countdown_s <= 0`` this is a no-op — useful + for headless scripts that want atomic start semantics without the + visual delay. + """ + if countdown_s <= 0: + return + + # Round up so non-integer durations still tick through every whole + # second. A value of 2.5 ticks "3 → 2 → 1" and sleeps 2.5 s total. + ticks = int(countdown_s) + if ticks < 1: + ticks = 1 + + remaining = countdown_s + for tick_value in range(ticks, 0, -1): + if on_tick is not None: + try: + on_tick(tick_value) + except Exception: # pragma: no cover — callback must not break start() + logger.exception("countdown tick callback raised") + step = remaining / tick_value + time.sleep(step) + remaining -= step + + +def _rollback_disconnect_streams(connected: List["Stream"]) -> None: + """Best-effort ``disconnect()`` on each stream, in LIFO order. + + Called during connect-rollback, stop-rollback, and the auto-connect + stop path. Exceptions from individual streams are logged at DEBUG + level and swallowed — tear-down must never leave a half-closed + device in place. + """ + for stream in reversed(connected): + try: + stream.disconnect() + except Exception as exc: # pragma: no cover — best-effort cleanup + logger.debug("disconnect() raised for %s: %s", stream.id, exc) + + +def _rollback_stop_recording(recording: List["Stream"]) -> None: + """Best-effort ``stop_recording()`` on each stream, in LIFO order. + + Called when ``start_recording()`` fails partway through the stream + list. The streams that did manage to start are told to stop + recording so the ones that succeeded don't keep writing after a + rollback. Return values are discarded — a rollback is not a + finalization. + """ + for stream in reversed(recording): + try: + stream.stop_recording() + except Exception as exc: # pragma: no cover — best-effort cleanup + logger.debug("stop_recording() raised for %s: %s", stream.id, exc) + + class SessionOrchestrator: """Coordinates a multi-stream recording session for one host. @@ -115,6 +233,17 @@ def __init__( self._chirp_stop: Optional[ChirpEmission] = None self._log_writer: Optional[SessionLogWriter] = None + # Which streams successfully ``connect()``-ed so ``disconnect()`` + # on a partial failure only tears down the ones that actually + # opened a device. + self._connected_streams: List[Stream] = [] + + # True when the operator used the legacy one-shot ``start()`` from + # ``IDLE`` instead of explicitly calling ``connect()`` first. + # In that case ``stop()`` also tears down the devices and lands + # the session in ``STOPPED`` for backward compatibility. + self._auto_connected: bool = False + # ------------------------------------------------------------------ # Public properties # ------------------------------------------------------------------ @@ -169,12 +298,21 @@ def add(self, stream: Stream) -> None: """Register a stream with this session. Must be called before :meth:`start`. Duplicate stream ids are - rejected so session output files are always unique. Once - ``start()`` has been called, any health events the stream emits - are forwarded to the session log automatically. + rejected so session output files are always unique, **and** + streams that point to the same physical device as one that's + already registered (matched by ``stream.device_key``) are + rejected too — this stops code + discovery-modal double-adds + from creating two cards for the same webcam. Streams that + return ``None`` from ``device_key`` (no hardware identity) + are compared on stream-id only. + + Once ``start()`` has been called, any health events the stream + emits are forwarded to the session log automatically. Raises: - ValueError: If a stream with the same id is already registered. + ValueError: If a stream with the same id is already + registered, or another stream already owns the same + physical device. RuntimeError: If the session is not in the ``IDLE`` state. """ if self._state is not SessionState.IDLE: @@ -183,80 +321,261 @@ def add(self, stream: Stream) -> None: ) if stream.id in self._streams: raise ValueError(f"duplicate stream id: {stream.id!r}") + new_key = getattr(stream, "device_key", None) + if new_key is not None: + for existing in self._streams.values(): + existing_key = getattr(existing, "device_key", None) + if existing_key == new_key: + raise ValueError( + f"physical device {new_key} is already registered " + f"as stream {existing.id!r}" + ) self._streams[stream.id] = stream stream.on_health(self._on_stream_health) + def remove(self, stream_id: str) -> None: + """Unregister a previously added stream. + + Valid in :attr:`SessionState.IDLE`, :attr:`CONNECTED`, and + :attr:`STOPPED` states. Refuses during ``CONNECTING``, + ``PREPARING``, ``COUNTDOWN``, ``RECORDING``, and ``STOPPING`` + because tearing a stream out of the session mid-lifecycle + would leave partial artifacts on disk. + + If the session is currently ``CONNECTED``, the stream's + device is disconnected first so its hardware handle is + released before the stream leaves the registry. + + Args: + stream_id: Id of the stream to remove. + + Raises: + KeyError: If ``stream_id`` is not registered. + RuntimeError: If the session is in a state that does not + allow stream removal. + """ + valid_states = ( + SessionState.IDLE, + SessionState.CONNECTED, + SessionState.STOPPED, + ) + with self._lock: + if self._state not in valid_states: + raise RuntimeError( + "remove() requires one of " + f"{[s.value for s in valid_states]}; current state is " + f"{self._state.value}" + ) + if stream_id not in self._streams: + raise KeyError(f"unknown stream id: {stream_id!r}") + + stream = self._streams[stream_id] + + # If the session is connected (live preview running), tear + # this stream's device down before unregistering so no + # background thread keeps a dead reference to it. + if self._state is SessionState.CONNECTED: + try: + stream.disconnect() + except Exception as exc: # pragma: no cover — best-effort + logger.debug( + "disconnect() raised while removing %s: %s", + stream_id, + exc, + ) + try: + self._connected_streams.remove(stream) + except ValueError: # pragma: no cover — defensive + pass + + del self._streams[stream_id] + logger.info("removed stream %s", stream_id) + # ------------------------------------------------------------------ - # Lifecycle — start + # Lifecycle — connect # ------------------------------------------------------------------ - def start(self) -> None: - """Start every registered stream atomically. + def connect(self) -> None: + """Open device I/O on every registered stream. - Sequence: - 1. Validate state (must be ``IDLE``) and that at least one - stream is registered. - 2. Capture a fresh :class:`~syncfield.types.SyncPoint` and - build the shared :class:`~syncfield.clock.SessionClock`. - 3. For each stream: call ``prepare()`` then - ``start(session_clock)``. If any call raises, roll back all - streams that were fully started (stopping them in reverse - order) and re-raise the original exception. - 4. On success, transition to ``RECORDING``. - - The failed stream itself is **not** rolled back — it never reached - a successfully-started state. + Transitions ``IDLE → CONNECTING → CONNECTED``. Each stream's + ``prepare()`` runs first (for permission checks and one-shot + setup) and then ``connect()`` opens the underlying device and + begins live capture for preview. After this call the viewer can + render ``latest_frame`` / plot values without any file being + written to disk. + + If any stream raises during ``prepare`` or ``connect``, every + stream that successfully connected so far is disconnected in + LIFO order and the exception re-raises. The session lands back + in ``IDLE`` with no lingering device handles. Raises: - RuntimeError: If state is not ``IDLE`` or no streams are - registered. - Exception: Any exception raised by a stream during - ``prepare``/``start`` propagates after rollback. State - returns to ``IDLE`` before the exception escapes. + RuntimeError: If the session is not in the ``IDLE`` or + ``STOPPED`` state, or if no streams are registered. + Exception: Any exception from a stream during prepare / + connect propagates after rollback. """ with self._lock: - if self._state is not SessionState.IDLE: + if self._state not in (SessionState.IDLE, SessionState.STOPPED): raise RuntimeError( - f"start() requires IDLE state; current state is {self._state.value}" + f"connect() requires IDLE or STOPPED state; current state is " + f"{self._state.value}" ) if not self._streams: - raise RuntimeError("cannot start() with no streams registered") + raise RuntimeError("cannot connect() with no streams registered") - # Open the crash-safe session log BEFORE any state mutation so - # failures during preparation are still recorded on disk. - self._log_writer = SessionLogWriter(self._output_dir) - self._log_writer.open() + # Open the crash-safe session log BEFORE any mutation so even + # a failure during the connect phase leaves a forensic trail. + if self._log_writer is None: + self._log_writer = SessionLogWriter(self._output_dir) + self._log_writer.open() - self._transition(SessionState.PREPARING) + self._transition(SessionState.CONNECTING) + + connected: List[Stream] = [] + try: + for stream in self._streams.values(): + stream.prepare() + stream.connect() + connected.append(stream) + except Exception as exc: + self._log_rollback(exc, len(connected)) + _rollback_disconnect_streams(connected) + self._transition(SessionState.IDLE) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + raise + + self._connected_streams = connected + self._transition(SessionState.CONNECTED) + + # ------------------------------------------------------------------ + # Lifecycle — start (countdown → record → chirp) + # ------------------------------------------------------------------ - # Leader: start advertising in the PREPARING state so - # followers already on the network see the session coming - # up. Follower: block here until a leader advertises - # `recording`. Both branches no-op for single-host. + def start( + self, + *, + countdown_s: float = 3.0, + on_countdown_tick: Optional[Callable[[int], None]] = None, + ) -> None: + """Run the countdown, start recording, and play the start chirp. + + Sequence: + 1. Validate state. If the session is ``IDLE``, auto-call + :meth:`connect` first so legacy callers that skip the + explicit connect step still work. + 2. Transition to ``COUNTDOWN`` and fire the optional + ``on_countdown_tick`` callback for each remaining second + (``3 → 2 → 1``). The viewer uses this to render a big + overlay countdown. + 3. Capture a fresh :class:`~syncfield.types.SyncPoint`. + 4. Call ``start_recording(session_clock)`` on every stream + in registration order. This is meant to be fast — + adapters should do any slow setup inside ``connect()``. + 5. If any stream raises, roll back by calling + ``stop_recording()`` on the streams that did start, then + return to ``CONNECTED`` and re-raise. + 6. Play the start chirp. The chirp is intentionally + **after** every stream has enabled file writing so the + audio track actually captures it. + 7. Transition to ``RECORDING``. + + Args: + countdown_s: How long to count down before recording starts. + Pass ``0`` to skip the countdown entirely (useful for + headless scripts). Default ``3.0`` seconds. + on_countdown_tick: Optional callback invoked once per + remaining second with the current tick value. Useful + for rendering a GUI overlay. Called on the calling + thread — the orchestrator does not spin up a worker. + + Raises: + RuntimeError: If the session is not in ``IDLE``, + ``STOPPED``, or ``CONNECTED`` states. + Exception: Any exception raised by a stream during + ``start_recording`` propagates after rollback. + """ + with self._lock: + if self._state in (SessionState.IDLE, SessionState.STOPPED): + # Legacy one-shot path — auto-connect then proceed. + self._auto_connected = True + self.connect() + elif self._state is not SessionState.CONNECTED: + raise RuntimeError( + f"start() requires CONNECTED state; current state is " + f"{self._state.value}" + ) + else: + self._auto_connected = False + + # Multi-host: advertise PREPARING / wait for leader. Moved + # out of the legacy PREPARING branch because CONNECTED + # already lets us know devices are live. + self._transition(SessionState.PREPARING) try: self._maybe_start_advertising() self._maybe_wait_for_leader() except Exception: self._stop_discovery_on_failure() - self._transition(SessionState.IDLE) + # Auto-connected sessions tear all the way back to IDLE + # on multi-host failure; explicit-connect sessions stay + # in CONNECTED so the caller can retry without + # re-opening hardware. + if self._auto_connected: + _rollback_disconnect_streams(self._connected_streams) + self._connected_streams = [] + self._auto_connected = False + self._transition(SessionState.IDLE) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + else: + self._transition(SessionState.CONNECTED) raise + # --- Countdown ------------------------------------------- + self._transition(SessionState.COUNTDOWN) + _run_countdown(countdown_s, on_countdown_tick) + + # --- Atomic start_recording ------------------------------ self._sync_point = SyncPoint.create_now(self._host_id) self._session_clock = SessionClock(sync_point=self._sync_point) - started: List[Stream] = [] + recording: List[Stream] = [] try: for stream in self._streams.values(): - stream.prepare() - stream.start(self._session_clock) - started.append(stream) + stream.start_recording(self._session_clock) + recording.append(stream) except Exception as exc: - self._log_rollback(exc, len(started)) - self._rollback_started_streams(started) + # Roll back the streams that did start writing. + self._log_rollback(exc, len(recording)) + _rollback_stop_recording(recording) self._stop_discovery_on_failure() - self._transition(SessionState.IDLE) + + # If the user took the legacy one-shot path through + # IDLE, tear down devices too and land in IDLE to + # preserve 0.1 rollback semantics. Explicit connect + # callers stay in CONNECTED so they can retry without + # re-opening hardware. + if self._auto_connected: + _rollback_disconnect_streams(self._connected_streams) + self._connected_streams = [] + self._auto_connected = False + self._transition(SessionState.IDLE) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + else: + self._transition(SessionState.CONNECTED) raise + # --- Start chirp — AFTER every stream is writing -------- + # This is the critical ordering: the chirp must land inside + # the recorded audio track, so we wait until every stream + # has enabled file writing before playing it. self._maybe_play_start_chirp() self._transition(SessionState.RECORDING) @@ -265,42 +584,32 @@ def start(self) -> None: # and streams are live. self._maybe_update_advert_recording() - @staticmethod - def _rollback_started_streams(started: List[Stream]) -> None: - """Best-effort tear-down of streams that were fully started. - - Called when ``start()`` fails partway through. Streams are stopped - in reverse order (LIFO) so later-started streams release their - resources before earlier-started ones. Any exceptions raised by - ``stop()`` during rollback are swallowed — the primary failure is - already on its way up the stack and is the real story. - """ - for s in reversed(started): - try: - s.stop() - except Exception: # pragma: no cover — best-effort cleanup - pass - # ------------------------------------------------------------------ # Lifecycle — stop # ------------------------------------------------------------------ def stop(self) -> SessionReport: - """Stop all streams and persist session artifacts. + """Play the stop chirp, finalize recording, and return to CONNECTED. Sequence: 1. Validate state (must be ``RECORDING``) and transition to ``STOPPING``. - 2. If chirp is eligible, play the stop chirp **before** - stopping streams so it lands in recording audio tracks, - then wait for its tail to flush. - 3. For each stream, call ``stop()``. Exceptions become failed - :class:`FinalizationReport` entries — one slow or broken - stream must never block finalization of the others. + 2. Play the stop chirp **before** any stream is told to + stop writing, so the chirp lands in every recorded audio + track. Wait for the chirp tail to flush. + 3. Call ``stop_recording()`` on every stream. Exceptions + become failed :class:`FinalizationReport` entries — one + slow or broken stream must never block finalization of + the others. 4. Write ``sync_point.json`` and ``manifest.json`` to the output directory. - 5. Transition to ``STOPPED``, close the session log, and - return the aggregated :class:`SessionReport`. + 5. Return the session to ``CONNECTED`` so the operator can + start another recording immediately without re-opening + hardware. Legacy one-shot callers (who reached + ``RECORDING`` via an auto-connect from ``IDLE``) are + taken all the way to ``STOPPED`` instead, matching the + 0.1 behavior. + 6. Return the aggregated :class:`SessionReport`. Returns: Aggregated :class:`SessionReport` with per-stream @@ -317,7 +626,12 @@ def stop(self) -> SessionReport: ) self._transition(SessionState.STOPPING) + # --- Stop chirp — BEFORE any stream is told to stop ----- + # This is the critical ordering: the chirp must be captured + # inside every recorded audio track, so we play it first + # and let its tail flush before telling streams to stop. self._maybe_play_stop_chirp_and_wait() + finalizations = self._finalize_streams() # Leader: flip advert status to stopped BEFORE closing the @@ -329,17 +643,25 @@ def stop(self) -> SessionReport: self._persist_session_artifacts(finalizations) - self._transition(SessionState.STOPPED) - if self._log_writer is not None: - self._log_writer.close() - self._log_writer = None - - # Tear down discovery. The advertiser's close() sleeps for - # graceful_shutdown_ms before unregistering so followers - # still browsing see the final "stopped" status; the - # browser closes immediately because the follower has - # already finalized its own streams. - self._stop_discovery_on_failure() + # --- Landing state ------------------------------------- + # If the caller explicitly connected before calling start, + # keep devices open so they can record again. Otherwise + # (legacy one-shot) tear down fully and land in STOPPED. + if self._auto_connected: + self._transition(SessionState.STOPPED) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + _rollback_disconnect_streams(self._connected_streams) + self._connected_streams = [] + self._stop_discovery_on_failure() + self._auto_connected = False + else: + self._transition(SessionState.CONNECTED) + # Leave the session log open for the next recording in + # this connected session. It gets flushed on each + # transition. + self._stop_discovery_on_failure() role_str = self._role.kind if self._role is not None else None return SessionReport( @@ -369,8 +691,39 @@ def stop(self) -> SessionReport: role=role_str, ) + # ------------------------------------------------------------------ + # Lifecycle — disconnect + # ------------------------------------------------------------------ + + def disconnect(self) -> None: + """Close device I/O on every connected stream. + + Transitions ``CONNECTED`` or ``STOPPED`` back to ``IDLE``. Each + stream's ``disconnect()`` is called in reverse registration + order so later-opened devices release their resources before + earlier-opened ones. Exceptions from individual streams are + logged and swallowed — tear-down must never leave a connected + device behind. + + Raises: + RuntimeError: If the session is in any state other than + ``CONNECTED`` / ``STOPPED``. + """ + with self._lock: + if self._state not in (SessionState.CONNECTED, SessionState.STOPPED): + raise RuntimeError( + f"disconnect() requires CONNECTED or STOPPED state; " + f"current state is {self._state.value}" + ) + _rollback_disconnect_streams(self._connected_streams) + self._connected_streams = [] + self._transition(SessionState.IDLE) + if self._log_writer is not None: + self._log_writer.close() + self._log_writer = None + def _finalize_streams(self) -> List[FinalizationReport]: - """Call ``stop()`` on each stream and collect FinalizationReports. + """Call ``stop_recording()`` on each stream and collect FinalizationReports. Stream exceptions are converted to failed reports so that one broken stream cannot prevent the session from reaching a clean @@ -380,7 +733,7 @@ def _finalize_streams(self) -> List[FinalizationReport]: finalizations: List[FinalizationReport] = [] for stream in self._streams.values(): try: - report = stream.stop() + report = stream.stop_recording() except Exception as exc: report = FinalizationReport( stream_id=stream.id, diff --git a/src/syncfield/stream.py b/src/syncfield/stream.py index bcc7648..44484df 100644 --- a/src/syncfield/stream.py +++ b/src/syncfield/stream.py @@ -9,7 +9,37 @@ their own inheritance tree can conform structurally. - **Base class** (:class:`StreamBase`) — a convenience superclass that handles callback registration and the internal health-event buffer so - concrete adapters only need to implement ``prepare``, ``start``, ``stop``. + concrete adapters only need to implement a small set of lifecycle + methods. + +Physical device identity +------------------------ + +Each stream optionally exposes a :attr:`Stream.device_key` — a stable +``(adapter_type, device_id)`` tuple that names the **physical hardware** +the stream is bound to. Two streams that target the same USB webcam, +the same BLE MAC, or the same OAK serial share one key. The +orchestrator uses it to reject duplicate hardware registration, and +the discovery modal uses it to show "already added" state for devices +the session already owns. Streams with no meaningful hardware identity +(e.g. :class:`JSONLFileStream` reading a user-owned file) return +``None`` and fall back to stream-id uniqueness only. + +Lifecycle +--------- + +SyncField 0.2 follows the same 4-phase lifecycle as the egonaut lab +recorder:: + + prepare() → connect() → start_recording() → stop_recording() → disconnect() + (once) (live preview) (file writing) (file close) (device close) + +Adapters that want the full flow override all four capture methods so +preview frames keep flowing while the orchestrator is in the ``CONNECTED`` +state, then file writing kicks in atomically on ``start_recording``. +Simpler adapters can override just ``prepare`` / ``start`` / ``stop`` — +:class:`StreamBase` provides backward-compatible defaults that route the +new methods through the legacy trio, so existing code keeps working. All reference adapters in :mod:`syncfield.adapters` inherit from :class:`StreamBase`. A third-party adapter is free to either inherit or @@ -18,7 +48,7 @@ from __future__ import annotations -from typing import Callable, List, Protocol, runtime_checkable +from typing import Callable, List, Optional, Protocol, Tuple, runtime_checkable from syncfield.clock import SessionClock from syncfield.types import ( @@ -33,35 +63,86 @@ SampleCallback = Callable[[SampleEvent], None] HealthCallback = Callable[[HealthEvent], None] +#: Stable identifier for the physical device a stream is bound to. +#: First element is the adapter type (``"uvc_webcam"``, ``"oak_camera"``, +#: …), second element is the per-device identifier (OpenCV index, OAK +#: mxid, BLE MAC, …). ``None`` means the stream has no meaningful +#: hardware identity — it will be compared on stream-id only. +DeviceKey = Tuple[str, str] + @runtime_checkable class Stream(Protocol): """Abstract contract for a capture source managed by SessionOrchestrator. Lifecycle: - 1. ``prepare()`` — acquire resources, check permissions. May be called - multiple times and must be idempotent. - 2. ``start(session_clock)`` — begin producing data, anchored to the - session's shared monotonic clock. - 3. ``stop()`` — cease production and return a - :class:`~syncfield.types.FinalizationReport`. + 1. ``prepare()`` — acquire one-shot resources (permissions, + handles). May be called multiple times and must be idempotent. + 2. ``connect()`` — open the device and begin live capture for + preview. After ``connect()`` the adapter must expose enough + state for the viewer to render something (``latest_frame`` for + video, plot data for sensors) but **must not** write to disk + yet. + 3. ``start_recording(session_clock)`` — begin writing the + captured data to the session output directory. This call + must be fast and atomic — any slow setup belongs in + ``connect()`` or ``prepare()``. All streams in a session + receive ``start_recording()`` inside the orchestrator's + ``COUNTDOWN → RECORDING`` transition, then the start chirp + plays. + 4. ``stop_recording()`` — stop writing and return a + :class:`~syncfield.types.FinalizationReport`. Called after + the orchestrator plays the stop chirp. The device may stay + open afterwards (``CONNECTED`` state) so the user can start + a new recording without re-opening hardware. + 5. ``disconnect()`` — close the device and release resources. + Called when the session returns to ``IDLE``. + + Legacy flow: + ``prepare() → start(session_clock) → stop()`` still works — the + default :class:`StreamBase` implementations of the new methods + route through the legacy trio so existing adapters keep running. + Adapters that want live preview should override the new + methods explicitly. Callbacks: - - ``on_sample(callback)`` — register a function called on every sample. - - ``on_health(callback)`` — register a function called on health events. + - ``on_sample(callback)`` — register a function called on every + sample. Samples should only flow during ``RECORDING``; the + preview path uses adapter-specific state like ``latest_frame``. + - ``on_health(callback)`` — register a function called on + health events. Thread safety: - Sample and health callbacks may be invoked from a background thread - owned by the stream. Callback functions must therefore be thread-safe. + Sample and health callbacks may be invoked from a background + thread owned by the stream. Callback functions must therefore + be thread-safe. """ id: str kind: StreamKind capabilities: StreamCapabilities + @property + def device_key(self) -> Optional[DeviceKey]: + """Stable identifier for the physical device, or ``None``. + + See :data:`DeviceKey` for the contract. Used by + ``SessionOrchestrator.add`` to reject duplicate hardware + registration and by the discovery modal to show "already + added" state. + """ + ... + def prepare(self) -> None: ... def start(self, session_clock: SessionClock) -> None: ... def stop(self) -> FinalizationReport: ... + # New 4-phase lifecycle methods. Adapters that don't override them + # inherit backward-compatible defaults from StreamBase that route + # through prepare/start/stop. + def connect(self) -> None: ... + def start_recording(self, session_clock: SessionClock) -> None: ... + def stop_recording(self) -> FinalizationReport: ... + def disconnect(self) -> None: ... def on_sample(self, callback: SampleCallback) -> None: ... def on_health(self, callback: HealthCallback) -> None: ... @@ -93,6 +174,17 @@ def __init__( self._health_callbacks: List[HealthCallback] = [] self._collected_health: List[HealthEvent] = [] + @property + def device_key(self) -> Optional[DeviceKey]: + """Return the physical device identifier, or ``None``. + + Default: ``None``. Override in adapters that wrap real hardware + so the orchestrator can reject duplicate registration and the + discovery modal can mark already-owned devices as "added". + See :data:`DeviceKey`. + """ + return None + def on_sample(self, callback: SampleCallback) -> None: """Register a callback invoked for every sample emitted by this stream.""" self._sample_callbacks.append(callback) @@ -117,14 +209,107 @@ def _emit_health(self, event: HealthEvent) -> None: cb(event) # ------------------------------------------------------------------ - # Lifecycle methods — subclasses must override. + # Lifecycle methods — subclasses override. # ------------------------------------------------------------------ + # + # Two levels of API here: + # + # 1. *Legacy trio* (``prepare`` / ``start`` / ``stop``) — the + # original 0.1 SPI. Existing adapters that only override these + # still work: the 4-phase defaults below route the new methods + # through the legacy ones. + # + # 2. *4-phase lifecycle* (``connect`` / ``start_recording`` / + # ``stop_recording`` / ``disconnect``) — added in 0.2 to support + # live preview before recording, atomic file writing, and + # reopen-friendly stop semantics. Adapters that want live preview + # should override these four directly and leave the legacy trio + # as no-ops (or keep them as thin convenience wrappers). + + def prepare(self) -> None: + """Acquire one-shot resources (permissions, handles). - def prepare(self) -> None: # pragma: no cover - abstract - raise NotImplementedError + Default: no-op. Override if your adapter needs to check + permissions or preload state before the device opens. + """ + pass def start(self, session_clock: SessionClock) -> None: # pragma: no cover - raise NotImplementedError + """Legacy one-shot start — open the device and begin writing. + + Default: raise. Legacy adapters override this; new-style + adapters override :meth:`connect` and :meth:`start_recording` + instead and leave this method alone. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement legacy start(). " + "Either override start() or override connect() + start_recording()." + ) def stop(self) -> FinalizationReport: # pragma: no cover - raise NotImplementedError + """Legacy one-shot stop — stop writing and close the device. + + Default: raise. Legacy adapters override this; new-style + adapters override :meth:`stop_recording` and :meth:`disconnect` + instead. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement legacy stop(). " + "Either override stop() or override stop_recording() + disconnect()." + ) + + # ------------------------------------------------------------------ + # 4-phase lifecycle — new in 0.2 + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Open the device and begin live preview capture. + + Called when the orchestrator transitions ``IDLE → CONNECTED``. + Override to open hardware and spawn the capture loop — data + should start flowing so the viewer can show a live preview, + but **do not** write anything to disk yet. + + Default: no-op. Backward-compat legacy adapters run everything + in ``start()`` which the orchestrator calls inside + :meth:`start_recording`. + """ + pass + + def start_recording(self, session_clock: SessionClock) -> None: + """Begin writing captured data to the session output. + + Called atomically on every stream right after the countdown + completes, **before** the start chirp plays. Must be fast — + any slow setup (opening a VideoWriter, allocating buffers) + belongs in :meth:`connect` or :meth:`prepare`, not here. + + Default: falls back to the legacy :meth:`start` so existing + adapters keep working without changes. New adapters should + override this and leave :meth:`start` alone. + """ + self.start(session_clock) + + def stop_recording(self) -> FinalizationReport: + """Stop writing and return a finalization report. + + Called after the orchestrator plays the stop chirp, so the + chirp is guaranteed to appear in any recorded audio track. + After this call the device is still connected — the user can + start another recording without re-opening hardware. + + Default: falls back to the legacy :meth:`stop`. + """ + return self.stop() + + def disconnect(self) -> None: + """Close the device and release capture resources. + + Called when the orchestrator transitions ``CONNECTED → IDLE`` + (or during a partial-failure rollback on ``start()``). After + ``disconnect()`` the adapter must not hold any OS handles. + + Default: no-op. Legacy adapters release resources inside + :meth:`stop` instead. + """ + pass diff --git a/src/syncfield/viewer/widgets/discovery_modal.py b/src/syncfield/viewer/widgets/discovery_modal.py index 4dc8502..c29d72d 100644 --- a/src/syncfield/viewer/widgets/discovery_modal.py +++ b/src/syncfield/viewer/widgets/discovery_modal.py @@ -47,6 +47,7 @@ import dearpygui.dearpygui as dpg from syncfield.viewer import theme +from syncfield.viewer.fonts import FontRegistry if TYPE_CHECKING: from syncfield.discovery import DiscoveredDevice, DiscoveryReport @@ -100,9 +101,11 @@ def __init__( self, session: "SessionOrchestrator", *, + fonts: Optional[FontRegistry] = None, on_added: Optional[Callable[[List["DiscoveredDevice"]], None]] = None, ) -> None: self._session = session + self._fonts = fonts or FontRegistry() self._on_added = on_added self._state = _ModalState() self._lock = threading.Lock() @@ -303,16 +306,39 @@ def _run_scan_worker(self) -> None: self._state.report = report self._state.error_message = error # Preselect every device that's ready to add (no warnings, - # not in use) so the common "everything looks good, just - # click Add" path is one click away. + # not in use, AND not already registered on the session) + # so the common "everything looks good, just click Add" + # path is one click away. Devices whose physical hardware + # is already owned by a registered stream are rendered as + # "already added" and their checkbox stays off by default. if report is not None: self._state.selected = { d.device_id for d in report.devices - if not d.warnings and not d.in_use + if not d.warnings + and not d.in_use + and self._already_registered_as(d) is None } self._state.needs_rebuild = True + def _already_registered_as( + self, device: "DiscoveredDevice" + ) -> Optional[str]: + """Return the stream id that already owns *device*, or ``None``. + + A discovered device is "already registered" when the current + session contains a stream whose ``device_key`` equals + ``(device.adapter_type, device.device_id)``. The check is + defensive against adapters that predate the ``device_key`` + property — ``getattr(..., None)`` falls through to ``None``. + """ + target = (device.adapter_type, device.device_id) + for stream in self._session._streams.values(): # noqa: SLF001 + key = getattr(stream, "device_key", None) + if key == target: + return stream.id + return None + # ------------------------------------------------------------------ # Rendering # ------------------------------------------------------------------ @@ -415,7 +441,12 @@ def _render_results(self, report: "DiscoveryReport") -> None: def _render_device_row(self, device: "DiscoveredDevice") -> None: """One row per discovered device — checkbox + two-line label.""" - addable = not device.warnings and not device.in_use + already_as = self._already_registered_as(device) + addable = ( + not device.warnings + and not device.in_use + and already_as is None + ) checkbox_tag = f"discovery::check_{device.device_id}" row_tag = f"discovery::row_{device.device_id}" @@ -444,6 +475,16 @@ def _render_device_row(self, device: "DiscoveredDevice") -> None: dpg.add_spacer(width=24) # align under the label dpg.add_text(" · ".join(sub_bits), color=theme.TEXT_MUTED) + # "Already added" row — physical device is already owned by + # a registered stream, so the checkbox is disabled above. + if already_as is not None: + with dpg.group(horizontal=True): + dpg.add_spacer(width=24) + dpg.add_text( + f"✓ Already added as '{already_as}'", + color=theme.TEXT_MUTED, + ) + # Warning row if the device can't be auto-added. if device.warnings: with dpg.group(horizontal=True): @@ -511,6 +552,14 @@ def _on_add_click(self) -> None: for device in report.devices: if device.device_id not in selected: continue + # Defense in depth: even though the checkbox for already- + # registered devices is disabled in the UI, re-check here + # so a stale ``selected`` snapshot from before a previous + # Add-click cannot resurrect a device the session already + # owns. The orchestrator would reject it anyway with a + # ValueError, but skipping here keeps the error log clean. + if self._already_registered_as(device) is not None: + continue try: stream_id = make_stream_id(device.display_name, existing_ids) kwargs: Dict[str, Any] = {"id": stream_id} diff --git a/src/syncfield/viewer/widgets/layout.py b/src/syncfield/viewer/widgets/layout.py index e7f1dbc..ea6df1a 100644 --- a/src/syncfield/viewer/widgets/layout.py +++ b/src/syncfield/viewer/widgets/layout.py @@ -27,6 +27,7 @@ from syncfield.orchestrator import SessionOrchestrator from syncfield.types import SessionState from syncfield.viewer import theme +from syncfield.viewer.fonts import FontRegistry from syncfield.viewer.state import SessionSnapshot from syncfield.viewer.widgets.discovery_modal import DiscoveryModal from syncfield.viewer.widgets.formatting import ( @@ -46,8 +47,19 @@ class ViewerLayout: from a worker thread so the render loop never blocks. """ - def __init__(self, session: SessionOrchestrator) -> None: + #: How long the countdown runs before recording actually begins. + #: The session clock panel overlays ``3 → 2 → 1`` in big display + #: numerals during this window. + COUNTDOWN_SECONDS: int = 3 + + def __init__( + self, + session: SessionOrchestrator, + *, + fonts: Optional[FontRegistry] = None, + ) -> None: self._session = session + self._fonts = fonts or FontRegistry() self._cards: Dict[str, StreamCard] = {} self._streams_row_tag = "streams_row" self._health_table_tag = "health_table" @@ -56,6 +68,11 @@ def __init__(self, session: SessionOrchestrator) -> None: # the header button. Holds its own DPG tags so the layout does # not need to know about its internals. self._discovery_modal: Optional[DiscoveryModal] = None + # Countdown state — populated by the Record callback, read by + # ``_update_clock_panel`` so the big overlay number stays in + # sync with ``SessionOrchestrator.start(on_countdown_tick=…)``. + self._countdown_value: Optional[int] = None + self._countdown_lock = threading.Lock() # ------------------------------------------------------------------ # Build (called once at viewer startup) @@ -70,14 +87,19 @@ def build(self) -> None: no_resize=True, no_collapse=True, no_bring_to_front_on_focus=True, + no_scrollbar=True, ): self._build_header() - dpg.add_spacer(height=8) + dpg.add_spacer(height=2) + dpg.add_separator() + dpg.add_spacer(height=10) self._build_control_and_clock_row() - dpg.add_spacer(height=8) + dpg.add_spacer(height=14) self._build_streams_section() - dpg.add_spacer(height=8) + dpg.add_spacer(height=14) self._build_health_section() + dpg.add_spacer(height=10) + dpg.add_separator() dpg.add_spacer(height=8) self._build_footer() @@ -94,48 +116,136 @@ def build(self) -> None: dpg.bind_item_theme("btn_cancel", self._ghost_theme) dpg.bind_item_theme("btn_discover", self._ghost_theme) + # Typography — bind prominent display fonts to the app title, + # timer, and host id so the header feels like a real app. + self._bind_fonts() + # Construct (but don't yet show) the discovery modal. Building # it here means the first click on the Discover button opens # an already-ready window instead of waiting for DPG to build # on demand. - self._discovery_modal = DiscoveryModal(self._session) + self._discovery_modal = DiscoveryModal(self._session, fonts=self._fonts) self._discovery_modal.build() + # Kick off the connect phase on a worker thread so the viewer + # can render its first frame before device I/O finishes (some + # adapters block for hundreds of ms on open). After the thread + # returns, the session is in CONNECTED and streams publish + # live preview data until the user hits Record. + self._auto_connect_on_build() + + def _auto_connect_on_build(self) -> None: + """Transition the session into CONNECTED on a worker thread. + + Called from :meth:`build`. The orchestrator's ``connect()`` is + fast for synthetic streams but slow for real hardware, so we + dispatch it off the render thread. Errors are logged and + swallowed — a failed connect leaves the session in ``IDLE`` + with the error visible in the session log and the viewer's + state chip. + """ + if self._session.state is not SessionState.IDLE: + return + if not self._session._streams: # noqa: SLF001 + return + threading.Thread( + target=self._safe_call, + args=(self._session.connect,), + name="viewer-auto-connect", + daemon=True, + ).start() + + def _bind_fonts(self) -> None: + """Assign per-widget fonts from the shared :class:`FontRegistry`. + + Safe to call with an empty registry — missing font tags become + no-ops, and the widget keeps whatever the global default font is. + """ + def bind(tag: str, font_tag: Optional[int]) -> None: + if font_tag is not None: + try: + dpg.bind_item_font(tag, font_tag) + except Exception: + pass + + # App title — display size + bind("app_title", self._fonts.ui_lg) + # Host id — monospace so varying-width ids don't jitter the header + bind("host_id_text", self._fonts.mono) + # State label — slightly larger than body for chip-like emphasis + bind("state_label", self._fonts.ui_md) + # Elapsed timer — monospace so digits don't shift sub-pixel + bind("elapsed_text", self._fonts.mono) + # Section titles + for tag in ( + "label_controls", + "label_clock", + "label_streams", + "label_health", + "label_output", + "label_wall_clock", + ): + bind(tag, self._fonts.ui_sm) + # Monospace clock values + bind("sync_point_text", self._fonts.mono) + bind("chirp_text", self._fonts.mono) + bind("wall_clock_text", self._fonts.mono) + bind("output_text", self._fonts.mono) + bind("tagline_text", self._fonts.ui_sm) + # Big countdown overlay — use the largest display font + bind("countdown_overlay", self._fonts.ui_lg) + # ------------------------------------------------------------------ # Sections # ------------------------------------------------------------------ def _build_header(self) -> None: - """Top row: logo, host id, state chip, elapsed timer, discover button.""" + """Top row: logo, host id, state chip, elapsed timer, discover button. + + Laid out as one horizontal group with a spring spacer that pushes + the Discover button to the far edge. Right-alignment is + approximate — DearPyGui doesn't have a real flexbox spacer, so we + compute the push width from :data:`theme.VIEWPORT_WIDTH`. The + primary window is pinned to the viewport so resize-driven drift + is acceptable for v1. + """ with dpg.group(horizontal=True): dpg.add_text("SyncField", tag="app_title") - dpg.add_spacer(width=12) - dpg.add_text("—", color=theme.TEXT_MUTED) - dpg.add_spacer(width=12) - dpg.add_text(self._session.host_id, tag="host_id_text") - dpg.add_spacer(width=20) + dpg.add_spacer(width=18) + dpg.add_text( + self._session.host_id, + tag="host_id_text", + color=theme.TEXT_SECONDARY, + ) + dpg.add_spacer(width=24) dpg.add_text("●", tag="state_dot", color=theme.STATE_IDLE) - dpg.add_spacer(width=4) - dpg.add_text("IDLE", tag="state_label", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=20) + dpg.add_spacer(width=6) + dpg.add_text( + "IDLE", + tag="state_label", + color=theme.TEXT_PRIMARY, + ) + dpg.add_spacer(width=18) dpg.add_text( "00:00.000", tag="elapsed_text", color=theme.TEXT_SECONDARY, ) - # Right-side spacer pushes the discover button to the edge. - dpg.add_spacer(width=220) + # Spring spacer — sized to leave room for the button at the + # right edge of the window's content area. + dpg.add_spacer(width=_header_spring_width()) dpg.add_button( - label="⚡ Discover devices", + label="Discover devices", tag="btn_discover", width=180, - height=30, + height=32, callback=self._on_discover_click, ) - dpg.add_spacer(height=4) + dpg.add_spacer(height=6) dpg.add_text( - "Capture orchestration — live session view", + "Capture orchestration · live session view", + tag="tagline_text", color=theme.TEXT_MUTED, ) @@ -150,33 +260,35 @@ def _build_control_and_clock_row(self) -> None: border=False, no_scrollbar=True, ): - dpg.add_text("CONTROLS", color=theme.TEXT_MUTED) - dpg.add_spacer(height=6) + dpg.add_text( + "CONTROLS", tag="label_controls", color=theme.TEXT_MUTED, + ) + dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button( - label="● Record", + label="Record", tag="btn_record", - width=110, + width=112, height=34, callback=self._on_record_click, ) dpg.add_button( - label="■ Stop", + label="Stop", tag="btn_stop", - width=90, + width=92, height=34, callback=self._on_stop_click, ) - dpg.add_spacer(height=6) + dpg.add_spacer(height=8) dpg.add_button( label="Cancel", tag="btn_cancel", - width=206, + width=212, height=28, callback=self._on_cancel_click, ) - dpg.add_spacer(width=12) + dpg.add_spacer(width=14) # --- Session clock + chirp panel -------------------------- with dpg.child_window( @@ -186,25 +298,51 @@ def _build_control_and_clock_row(self) -> None: border=False, no_scrollbar=True, ): - dpg.add_text("SESSION CLOCK", color=theme.TEXT_MUTED) - dpg.add_spacer(height=6) - with dpg.group(horizontal=True): - dpg.add_text("sync_point", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=8) - dpg.add_text("—", tag="sync_point_text") - with dpg.group(horizontal=True): - dpg.add_text("chirp", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=38) - dpg.add_text("pending", tag="chirp_text") + # Header row — section label on the left, big countdown + # number on the right. The countdown is hidden by + # default and ``_update_clock_panel`` toggles it + # visible whenever the orchestrator is in the + # COUNTDOWN state. with dpg.group(horizontal=True): - dpg.add_text("tone", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=42) - dpg.add_text("—", tag="tone_text") + dpg.add_text( + "SESSION CLOCK", + tag="label_clock", + color=theme.TEXT_MUTED, + ) + dpg.add_spacer(width=12) + dpg.add_text( + "", + tag="countdown_overlay", + color=theme.ACCENT, + show=False, + ) + dpg.add_spacer(height=12) + + # Key / value strip — one row per field, fixed-width + # label column so values line up. A plain horizontal + # group with a single inline spacer avoids the extra + # vertical padding a nested fixed-width group would add. + def _kv_row(label_text: str, value_tag: str, default: str) -> None: + with dpg.group(horizontal=True): + dpg.add_text(label_text, color=theme.TEXT_SECONDARY) + # Trailing spacer width is computed from the + # longest label ("sync_point") so every value + # column aligns on the same x coordinate. + pad = _kv_label_pad(label_text) + if pad > 0: + dpg.add_spacer(width=pad) + dpg.add_text(default, tag=value_tag) + + _kv_row("sync_point", "sync_point_text", "—") + _kv_row("chirp", "chirp_text", "pending") + _kv_row("tone", "tone_text", "—") def _build_streams_section(self) -> None: """Horizontal scrollable row of stream cards.""" - dpg.add_text("STREAMS", color=theme.TEXT_MUTED) - dpg.add_spacer(height=4) + dpg.add_text( + "STREAMS", tag="label_streams", color=theme.TEXT_MUTED, + ) + dpg.add_spacer(height=8) with dpg.child_window( tag="streams_container", width=-1, @@ -217,8 +355,10 @@ def _build_streams_section(self) -> None: def _build_health_section(self) -> None: """A table of recent health events.""" - dpg.add_text("HEALTH EVENTS", color=theme.TEXT_MUTED) - dpg.add_spacer(height=4) + dpg.add_text( + "HEALTH EVENTS", tag="label_health", color=theme.TEXT_MUTED, + ) + dpg.add_spacer(height=8) with dpg.child_window( width=-1, height=theme.HEALTH_SECTION_HEIGHT, @@ -242,14 +382,14 @@ def _build_health_section(self) -> None: dpg.add_table_column(label="Detail") def _build_footer(self) -> None: - """Output path and wall clock.""" + """Output path and wall clock, as a two-column key/value strip.""" with dpg.group(horizontal=True): - dpg.add_text("output", color=theme.TEXT_MUTED) - dpg.add_spacer(width=8) + dpg.add_text("output", tag="label_output", color=theme.TEXT_MUTED) + dpg.add_spacer(width=_kv_label_pad("output")) dpg.add_text("—", tag="output_text", color=theme.TEXT_SECONDARY) with dpg.group(horizontal=True): - dpg.add_text("wall clock", color=theme.TEXT_MUTED) - dpg.add_spacer(width=8) + dpg.add_text("wall clock", tag="label_wall_clock", color=theme.TEXT_MUTED) + dpg.add_spacer(width=_kv_label_pad("wall clock")) dpg.add_text("—", tag="wall_clock_text", color=theme.TEXT_SECONDARY) # ------------------------------------------------------------------ @@ -297,33 +437,94 @@ def _update_clock_panel(self, snapshot: SessionSnapshot) -> None: "400 → 2500 Hz, 500 ms" if snapshot.chirp_enabled else "—", ) + # Countdown overlay — visible only while the orchestrator is + # in the COUNTDOWN state. The big number is pulled from the + # worker-thread-populated ``_countdown_value`` under a lock. + if snapshot.state == "countdown": + with self._countdown_lock: + value = self._countdown_value + if value is not None: + dpg.set_value("countdown_overlay", f"· {value} ·") + dpg.configure_item("countdown_overlay", show=True) + else: + dpg.configure_item("countdown_overlay", show=False) + def _update_controls(self, snapshot: SessionSnapshot) -> None: + """Enable/disable the session control buttons based on state. + + The 0.2 lifecycle has more states than the 0.1 one: + + * ``IDLE`` / ``CONNECTING`` — nothing is enabled. The viewer + is about to transition into CONNECTED via the auto-connect + worker thread; buttons stay greyed-out until it lands. + * ``CONNECTED`` — Record is primary-enabled. Discovery is + also allowed since no recording is in flight. + * ``COUNTDOWN`` / ``PREPARING`` — all buttons disabled except + Cancel (which aborts the countdown or the prepare phase). + * ``RECORDING`` — Stop is the primary action; Cancel also + triggers a stop (best-effort path). + * ``STOPPING`` / ``STOPPED`` — everything disabled while the + finalize path runs. After STOPPED, the auto-connect path + teardown has completed and the viewer is typically closing. + """ state = snapshot.state - # Enable/disable the three buttons based on the orchestrator state. - _set_enabled("btn_record", state == "idle") + _set_enabled("btn_record", state == "connected") _set_enabled("btn_stop", state == "recording") - _set_enabled("btn_cancel", state in ("preparing", "recording")) - # Discovery only makes sense before recording — the session's - # ``add()`` contract refuses new streams once ``start()`` has - # been called. - _set_enabled("btn_discover", state == "idle") + _set_enabled( + "btn_cancel", + state in ("preparing", "countdown", "recording"), + ) + # Discovery is allowed during the preview phase (CONNECTED) + # because ``scan_and_add`` refuses if the session is actually + # recording, so the registry add path stays safe. + _set_enabled("btn_discover", state == "connected") def _update_streams(self, snapshot: SessionSnapshot, now_ns: int) -> None: # Create cards for new streams. for stream_id, stream_snap in snapshot.streams.items(): if stream_id not in self._cards: - self._cards[stream_id] = StreamCard(self._streams_row_tag, stream_snap) - self._cards[stream_id].update(stream_snap, now_ns) + self._cards[stream_id] = StreamCard( + self._streams_row_tag, + stream_snap, + fonts=self._fonts, + on_remove=self._request_remove_stream, + ) + self._cards[stream_id].update( + stream_snap, now_ns, session_state=snapshot.state + ) - # Cards for streams that were removed (rare, but keep the UI honest). + # Cards for streams that were removed (either by the × button on + # the card itself, via code, or by a rollback). Pop the card from + # our dict and delete its DPG node so the row reflows. removed = set(self._cards.keys()) - set(snapshot.streams.keys()) for stream_id in removed: card = self._cards.pop(stream_id) try: - dpg.delete_item(card._card_tag) + dpg.delete_item(card._card_tag) # noqa: SLF001 except Exception: pass + def _request_remove_stream(self, stream_id: str) -> None: + """Drive ``SessionOrchestrator.remove`` on a worker thread. + + Called by a stream card's × button. The orchestrator's + ``remove()`` may call ``stream.disconnect()`` on a live + hardware handle, which can take tens of ms, so we never run it + on the DPG render thread. On success the next poller tick + notices that ``stream_id`` dropped out of the session and the + render loop deletes the DPG card node in + :meth:`_update_streams`. + + Failures are swallowed via :meth:`_safe_call` (logged at + ERROR) so a transient remove error never freezes the UI. + """ + threading.Thread( + target=self._safe_call, + args=(lambda: self._session.remove(stream_id),), + name=f"viewer-remove-{stream_id}", + daemon=True, + ).start() + def _update_health(self, snapshot: SessionSnapshot) -> None: """Rebuild the health table when the event set changes. @@ -369,13 +570,47 @@ def _update_footer(self, snapshot: SessionSnapshot) -> None: # ------------------------------------------------------------------ def _on_record_click(self) -> None: + """Trigger the full start flow: countdown → record → chirp. + + Dispatched onto a worker thread so the render loop stays + responsive while the countdown sleeps and the streams begin + writing. The orchestrator fires ``on_countdown_tick`` once + per remaining second; the callback stores the value on the + shared lock so the next render frame's ``_update_clock_panel`` + shows the big overlay. + """ + def _run_start() -> None: + try: + self._session.start( + countdown_s=self.COUNTDOWN_SECONDS, + on_countdown_tick=self._on_countdown_tick, + ) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "Viewer session.start() failed" + ) + finally: + with self._countdown_lock: + self._countdown_value = None + threading.Thread( - target=self._safe_call, - args=(self._session.start,), + target=_run_start, name="viewer-ctrl-start", daemon=True, ).start() + def _on_countdown_tick(self, n: int) -> None: + """Called from the orchestrator's start worker, per tick. + + Runs on the worker thread, not the render thread — we just + store the value under a lock and let the next frame's + ``_update_clock_panel`` call render it. + """ + with self._countdown_lock: + self._countdown_value = n + def _on_stop_click(self) -> None: threading.Thread( target=self._safe_call, @@ -385,23 +620,91 @@ def _on_stop_click(self) -> None: ).start() def _on_cancel_click(self) -> None: - """Cancel is SessionOrchestrator.stop() if recording — the SDK - has no dedicated cancel primitive, so we call stop() which takes - the best-effort path. Applications with richer cancellation can - subclass this layout in the future.""" - self._on_stop_click() + """Cancel during COUNTDOWN or RECORDING. + + The SDK has no dedicated cancel primitive; calling + :meth:`SessionOrchestrator.stop` takes the best-effort path + for both cases. Cancelling during the countdown is + interpreted as "don't record this one" — because no stream + has received ``start_recording`` yet, the stop path is a + no-op on the streams and the chirp is skipped. + """ + state = self._session.state + if state is SessionState.RECORDING: + self._on_stop_click() + elif state is SessionState.COUNTDOWN: + # We can't interrupt the countdown sleep from here, but + # we can mark the user intent so when the countdown + # finishes the subsequent stop picks it up. For v1 this + # is a soft cancel: the recording starts briefly and + # then immediately stops. + def _cancel_after_start() -> None: + # Wait for the session to leave COUNTDOWN + import time as _t + + deadline = _t.monotonic() + 5.0 + while _t.monotonic() < deadline: + if self._session.state is SessionState.RECORDING: + try: + self._session.stop() + except Exception: + pass + return + _t.sleep(0.05) + + threading.Thread( + target=_cancel_after_start, + name="viewer-ctrl-cancel", + daemon=True, + ).start() def _on_discover_click(self) -> None: - """Open the discovery modal. Disabled while recording to keep the - registry-add path out of a live session's hot path.""" - if self._session.state is not SessionState.IDLE: - # Silently ignore — the add button will be disabled anyway, - # and the visual affordance in the header tells the user to - # stop the session first. + """Open the discovery modal. + + Allowed from ``IDLE`` / ``CONNECTED`` / ``STOPPED``. Silently + ignored while a recording is in flight — ``scan_and_add`` + refuses anyway, and the button disables itself in those + states. + """ + if self._session.state not in ( + SessionState.IDLE, + SessionState.CONNECTED, + SessionState.STOPPED, + ): return if self._discovery_modal is not None: self._discovery_modal.open() + # ------------------------------------------------------------------ + # Session lifecycle teardown — called by the viewer app on close + # ------------------------------------------------------------------ + + def teardown_session(self) -> None: + """Return the session to ``IDLE`` when the viewer is closing. + + Called from :class:`ViewerApp.close`. Handles all the + intermediate states the session might be in when the user + closes the window mid-recording: + + * ``RECORDING`` → stop() then disconnect() + * ``CONNECTED`` / ``STOPPED`` → disconnect() + * everything else → best-effort, swallow errors + + Runs on the caller's thread (the viewer shutdown path) so the + orchestrator's lifecycle lock is respected. + """ + try: + if self._session.state is SessionState.RECORDING: + self._session.stop() + if self._session.state in (SessionState.CONNECTED, SessionState.STOPPED): + self._session.disconnect() + except Exception: + import logging + + logging.getLogger(__name__).exception( + "Viewer session teardown failed" + ) + @staticmethod def _safe_call(fn) -> None: try: @@ -419,6 +722,55 @@ def _safe_call(fn) -> None: # --------------------------------------------------------------------------- +def _kv_label_pad(label: str) -> int: + """Return the pixel spacer width that aligns a value column for ``label``. + + The viewer's key/value strips (session clock, footer) use the longest + label in the column as the alignment anchor. This helper hard-codes + the pixel widths because DearPyGui doesn't expose a text-metrics API + before the viewport is shown — at build time the font has been + loaded but the renderer's atlas is not yet available. + + The numbers were measured at 15 px SF Pro (the viewer's default body + font) against a 90 px value column anchor. Fonts at other sizes drift + a few pixels but the layout reads correctly down to 13 px. + """ + # Keyed by label — one entry per text we render in a key/value row. + # Anchor column x = 100 px from the start of the row. + _ANCHOR_X = 100 + _LABEL_W = { + "sync_point": 76, + "chirp": 38, + "tone": 33, + "output": 48, + "wall clock": 74, + } + width = _LABEL_W.get(label, 0) + return max(8, _ANCHOR_X - width) + + +def _header_spring_width() -> int: + """Return a spacer width that roughly right-aligns the Discover button. + + DearPyGui has no flexbox spring spacer, so we compute the gap from + the fixed viewport width minus the estimated left-cluster width and + the button's declared width. This is intentionally rough — the + primary window is pinned to a 1200 px viewport in the default + layout, so the approximation is good enough for the common case and + the layout does not need to react to resize. + """ + # Window content width = viewport width - left/right window padding. + content_w = theme.VIEWPORT_WIDTH - 2 * theme.WINDOW_PADDING[0] + # Rough pixel width of the left cluster (title + host + state + timer + # + fixed spacers). Overestimates slightly so the button never gets + # clipped when the timer grows to ``99:59.999``. + left_cluster_w = 430 + # Discover button declared width. + button_w = 180 + spring = content_w - left_cluster_w - button_w + return max(40, spring) + + def _set_enabled(tag: str, enabled: bool) -> None: try: if enabled: diff --git a/src/syncfield/viewer/widgets/stream_card.py b/src/syncfield/viewer/widgets/stream_card.py index 2bf8684..cd55a62 100644 --- a/src/syncfield/viewer/widgets/stream_card.py +++ b/src/syncfield/viewer/widgets/stream_card.py @@ -14,12 +14,13 @@ from __future__ import annotations import time -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional import dearpygui.dearpygui as dpg import numpy as np from syncfield.viewer import theme +from syncfield.viewer.fonts import FontRegistry from syncfield.viewer.state import StreamSnapshot from syncfield.viewer.widgets.formatting import ( format_count, @@ -27,6 +28,11 @@ format_ns_ago, ) +#: Session states in which a stream may be removed from the live session. +#: Kept in sync with :meth:`syncfield.orchestrator.SessionOrchestrator.remove` +#: — any state outside this set disables the remove button on every card. +_REMOVABLE_STATES = frozenset({"idle", "connected", "stopped"}) + # Texture resolution for video previews. We keep this fixed so all cards # share one preset; real frames are resized (with aspect-ratio letterboxing) @@ -43,9 +49,18 @@ class StreamCard: stream additions Just Work. """ - def __init__(self, parent_tag: str, snapshot: StreamSnapshot) -> None: + def __init__( + self, + parent_tag: str, + snapshot: StreamSnapshot, + *, + fonts: Optional[FontRegistry] = None, + on_remove: Optional[Callable[[str], None]] = None, + ) -> None: self._stream_id = snapshot.id self._kind = snapshot.kind + self._fonts = fonts or FontRegistry() + self._on_remove = on_remove self._card_tag = f"card::{snapshot.id}" self._title_tag = f"card_title::{snapshot.id}" self._state_dot_tag = f"card_dot::{snapshot.id}" @@ -53,6 +68,8 @@ def __init__(self, parent_tag: str, snapshot: StreamSnapshot) -> None: self._hz_tag = f"card_hz::{snapshot.id}" self._last_sample_tag = f"card_last::{snapshot.id}" self._capability_tag = f"card_cap::{snapshot.id}" + self._remove_button_tag = f"card_remove::{snapshot.id}" + self._last_remove_enabled: Optional[bool] = None # Variant-specific tags (populated by the matching _build_body method) self._texture_tag: Optional[str] = None @@ -78,21 +95,64 @@ def _build(self, parent_tag: str, snapshot: StreamSnapshot) -> None: ): dpg.bind_item_theme(self._card_tag, theme.build_card_theme()) - # --- Header row: stream id + status dot ------------------- + # --- Header row: stream id + status dot + remove button --- + # + # We right-pin the ``×`` button by pre-computing the spacer + # width from the card width. DPG has no flexbox; a fixed + # spacer is the simplest way to keep the remove button + # anchored to the card's top-right corner regardless of the + # stream id's length. + _REMOVE_BUTTON_W = 22 + _CONTENT_PADDING = 14 # DPG child_window inner padding + _HEADER_GAP = 6 with dpg.group(horizontal=True): dpg.add_text(snapshot.id, tag=self._title_tag) - dpg.add_spacer(width=4) + dpg.add_spacer(width=_HEADER_GAP) dpg.add_text( "●", tag=self._state_dot_tag, color=theme.SUCCESS, ) + # Push the × to the right edge. We assume the id fits + # in the default header width; longer ids bleed into + # the spacer first before clipping the button. + spacer_w = max( + 4, + theme.CARD_WIDTH + - _CONTENT_PADDING * 2 + - _REMOVE_BUTTON_W + - 60, # rough width budget for id text + dot + ) + dpg.add_spacer(width=spacer_w) + dpg.add_button( + label="×", + tag=self._remove_button_tag, + width=_REMOVE_BUTTON_W, + height=_REMOVE_BUTTON_W, + callback=self._on_remove_click, + ) + dpg.bind_item_theme( + self._remove_button_tag, + theme.build_ghost_button_theme(), + ) dpg.add_text( _capability_label(snapshot), tag=self._capability_tag, - color=theme.TEXT_SECONDARY, + color=theme.TEXT_MUTED, ) - dpg.add_spacer(height=6) + dpg.add_spacer(height=8) + + # Bind card title to the emphasized font once the tag exists. + if self._fonts.ui_md is not None: + try: + dpg.bind_item_font(self._title_tag, self._fonts.ui_md) + except Exception: + pass + if self._fonts.ui_sm is not None: + try: + dpg.bind_item_font(self._capability_tag, self._fonts.ui_sm) + except Exception: + pass # --- Body: variant-specific -------------------------------- if self._kind == "video": @@ -102,16 +162,18 @@ def _build(self, parent_tag: str, snapshot: StreamSnapshot) -> None: else: self._build_stats_body() - dpg.add_spacer(height=6) + dpg.add_spacer(height=8) # --- Footer stats row ------------------------------------- with dpg.group(horizontal=True): dpg.add_text( format_count(snapshot.frame_count), tag=self._frame_count_tag, + color=theme.TEXT_PRIMARY, ) + dpg.add_spacer(width=4) dpg.add_text("frames", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=10) + dpg.add_spacer(width=14) dpg.add_text( format_hz(snapshot.effective_hz), tag=self._hz_tag, @@ -123,6 +185,19 @@ def _build(self, parent_tag: str, snapshot: StreamSnapshot) -> None: color=theme.TEXT_MUTED, ) + # Stats row uses monospace so numeric counters don't jitter. + if self._fonts.mono is not None: + try: + dpg.bind_item_font(self._frame_count_tag, self._fonts.mono) + dpg.bind_item_font(self._hz_tag, self._fonts.mono) + except Exception: + pass + if self._fonts.ui_sm is not None: + try: + dpg.bind_item_font(self._last_sample_tag, self._fonts.ui_sm) + except Exception: + pass + def _build_video_body(self, snapshot: StreamSnapshot) -> None: """A raw-texture image that the render loop updates in place.""" self._texture_tag = f"texture::{snapshot.id}" @@ -174,8 +249,24 @@ def _build_stats_body(self) -> None: # Update — called every render frame # ------------------------------------------------------------------ - def update(self, snapshot: StreamSnapshot, now_ns: int) -> None: - """Sync this card to the newest snapshot.""" + def update( + self, + snapshot: StreamSnapshot, + now_ns: int, + session_state: Optional[str] = None, + ) -> None: + """Sync this card to the newest snapshot. + + Args: + snapshot: Latest per-stream data. + now_ns: Monotonic ns for staleness comparisons. + session_state: Lowercase session state string (see + :attr:`SessionSnapshot.state`). Used to enable or + disable the remove button — removal is only legal in + ``idle`` / ``connected`` / ``stopped``. ``None`` means + "don't touch the button" (the initial state set at + build time). + """ dpg.set_value(self._frame_count_tag, format_count(snapshot.frame_count)) dpg.set_value(self._hz_tag, format_hz(snapshot.effective_hz)) dpg.set_value( @@ -187,11 +278,43 @@ def update(self, snapshot: StreamSnapshot, now_ns: int) -> None: color=_dot_color(snapshot, now_ns), ) + if session_state is not None: + self._sync_remove_button_enabled(session_state) + if self._kind == "video": self._update_video_texture(snapshot) elif self._kind in ("sensor", "audio"): self._update_plot(snapshot) + def _sync_remove_button_enabled(self, session_state: str) -> None: + """Enable the remove button only in removal-safe session states. + + Caches the last enabled/disabled state so DPG doesn't get a + fresh ``configure_item`` call every render frame — the check + is a cheap string membership test + equality. + """ + should_enable = session_state in _REMOVABLE_STATES + if should_enable == self._last_remove_enabled: + return + try: + if should_enable: + dpg.enable_item(self._remove_button_tag) + else: + dpg.disable_item(self._remove_button_tag) + except Exception: # pragma: no cover — DPG not yet ready at first tick + return + self._last_remove_enabled = should_enable + + def _on_remove_click(self, sender=None, app_data=None, user_data=None) -> None: + """Fire the injected remove callback with this card's stream id. + + The callback (owned by :class:`ViewerLayout`) is expected to + call :meth:`SessionOrchestrator.remove` on a worker thread so + the UI thread doesn't block on device teardown. + """ + if self._on_remove is not None: + self._on_remove(self._stream_id) + def _update_video_texture(self, snapshot: StreamSnapshot) -> None: """Upload the latest frame to the GPU texture, with letterboxing.""" if self._texture_tag is None: diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 6bbffde..e7b3f68 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -82,6 +82,22 @@ def test_output_dir_created(self, tmp_path): assert target.exists() +class _DeviceKeyedFakeStream(FakeStream): + """FakeStream variant that advertises a physical device key. + + Used to exercise :meth:`SessionOrchestrator.add` duplicate-device + detection without needing real hardware adapters. + """ + + def __init__(self, id, device_key, **kwargs): + super().__init__(id=id, **kwargs) + self._device_key = device_key + + @property + def device_key(self): + return self._device_key + + class TestAdd: def test_add_stream_in_idle_state(self, tmp_path): session = _session(tmp_path) @@ -94,6 +110,102 @@ def test_rejects_duplicate_stream_id(self, tmp_path): with pytest.raises(ValueError, match="duplicate stream id"): session.add(FakeStream("cam")) + def test_rejects_duplicate_physical_device(self, tmp_path): + """Same (adapter_type, device_id) cannot be registered twice. + + Regression for the case where a user registered a camera in + code and then ran Discover devices in the viewer — both paths + succeeded and the session ended up with two cards for the + same physical webcam. + """ + session = _session(tmp_path) + session.add( + _DeviceKeyedFakeStream("mac_webcam", ("uvc_webcam", "0")) + ) + with pytest.raises(ValueError, match="already registered as stream"): + session.add( + _DeviceKeyedFakeStream("macbook_pro", ("uvc_webcam", "0")) + ) + + def test_different_device_keys_are_not_duplicates(self, tmp_path): + """Two streams on different device indices register cleanly.""" + session = _session(tmp_path) + session.add( + _DeviceKeyedFakeStream("mac_webcam", ("uvc_webcam", "0")) + ) + session.add( + _DeviceKeyedFakeStream("iphone", ("uvc_webcam", "1")) + ) + assert len(session._streams) == 2 + + def test_none_device_keys_fall_back_to_id_uniqueness(self, tmp_path): + """Streams with no hardware identity (device_key == None) are + only compared on stream id — two unique-id FakeStreams with + no device_key must both register. + """ + session = _session(tmp_path) + session.add(FakeStream("a")) # FakeStream default device_key == None + session.add(FakeStream("b")) + assert len(session._streams) == 2 + + +class TestRemove: + def test_remove_from_idle_state(self, tmp_path): + session = _session(tmp_path) + session.add(FakeStream("cam")) + assert "cam" in session._streams + session.remove("cam") + assert "cam" not in session._streams + assert session.state is SessionState.IDLE + + def test_remove_unknown_stream_raises_key_error(self, tmp_path): + session = _session(tmp_path) + with pytest.raises(KeyError, match="unknown stream id"): + session.remove("ghost") + + def test_remove_rejected_during_recording(self, tmp_path): + """Tearing a stream out of a live recording is not allowed.""" + session = _session(tmp_path) + session.add(FakeStream("a")) + session.add(FakeStream("b")) + session.start() + try: + with pytest.raises(RuntimeError, match="remove.*requires"): + session.remove("a") + # Stream is still there after the failed remove. + assert "a" in session._streams + finally: + session.stop() + + def test_remove_after_stop_allowed(self, tmp_path): + """STOPPED is a valid state for removal — the session can be + rebuilt with a different set of streams after one recording. + """ + session = _session(tmp_path) + session.add(FakeStream("a")) + session.add(FakeStream("b")) + session.start() + session.stop() + assert session.state is SessionState.STOPPED + session.remove("a") + assert "a" not in session._streams + assert "b" in session._streams + + def test_remove_frees_device_key_for_re_add(self, tmp_path): + """After removing a stream its device_key should free up so a + fresh stream can grab the same hardware. + """ + session = _session(tmp_path) + session.add( + _DeviceKeyedFakeStream("mac_webcam", ("uvc_webcam", "0")) + ) + session.remove("mac_webcam") + # Same device_key is no longer claimed. + session.add( + _DeviceKeyedFakeStream("mac_webcam_v2", ("uvc_webcam", "0")) + ) + assert "mac_webcam_v2" in session._streams + class TestStartHappyPath: def test_start_transitions_to_recording(self, tmp_path): @@ -171,6 +283,16 @@ def test_failure_during_start_rolls_back_prior_streams(self, tmp_path): assert session.state is SessionState.IDLE def test_failure_during_prepare_stops_earlier_streams(self, tmp_path): + """A failure in ``prepare()`` happens during the connect phase, + which runs all preparations before any stream starts recording. + The rollback therefore calls ``disconnect()`` on streams that + connected — and ``start()`` is never reached on any of them. + + This differs from the 0.1 behaviour where ``prepare()`` and + ``start()`` interleaved per stream; the 0.2 orchestrator splits + the two phases so all devices connect before any begin writing, + matching the egonaut lab recorder's 2-phase model. + """ session = _session(tmp_path) good = FakeStream("a") bad = FakeStream("b", fail_on_prepare=True) @@ -180,14 +302,93 @@ def test_failure_during_prepare_stops_earlier_streams(self, tmp_path): with pytest.raises(RuntimeError, match="fake failure in prepare"): session.start() + # prepare() ran on both in the connect phase assert good.prepare_calls == 1 - assert good.start_calls == 1 # fully started - assert good.stop_calls == 1 # then rolled back assert bad.prepare_calls == 1 - assert bad.start_calls == 0 # never reached start + # start_recording() was never invoked because the connect phase failed + assert good.start_calls == 0 + assert bad.start_calls == 0 + # Rollback returned the auto-connected session to IDLE + assert session.state is SessionState.IDLE + + +class TestFourPhaseLifecycle: + """Cover the 0.2 explicit ``connect → start → stop → disconnect`` path. + + The legacy one-shot ``start() / stop()`` path is still exercised by + :class:`TestStartHappyPath` and :class:`TestStop`. This class pins + the newer semantics down: + * ``connect()`` transitions ``IDLE → CONNECTING → CONNECTED`` and + calls each stream's ``prepare()`` and ``connect()`` methods. + * ``start(countdown_s=0)`` walks ``CONNECTED → PREPARING → + COUNTDOWN → RECORDING`` and calls ``start_recording()`` on + every stream (which routes to ``start()`` on a legacy + :class:`FakeStream`). + * ``stop()`` returns to ``CONNECTED`` (not ``STOPPED``) so the + operator can record another episode without reopening devices. + * ``disconnect()`` brings the session back to ``IDLE``. + """ + + def test_connect_start_stop_disconnect_happy_path(self, tmp_path): + session = _session(tmp_path) + fs = FakeStream("cam") + session.add(fs) + + session.connect() + assert session.state is SessionState.CONNECTED + assert fs.prepare_calls == 1 + + session.start(countdown_s=0) + assert session.state is SessionState.RECORDING + assert fs.start_calls == 1 # start_recording() → legacy start() + + report = session.stop() + # Explicit-connect path stays in CONNECTED after stop so the + # operator can record the next episode immediately. + assert session.state is SessionState.CONNECTED + assert fs.stop_calls == 1 # stop_recording() → legacy stop() + assert report.host_id == "rig_01" + + session.disconnect() assert session.state is SessionState.IDLE + def test_start_from_connected_does_not_auto_disconnect_on_stop(self, tmp_path): + """Explicit connect + stop leaves devices open; a new start works.""" + session = _session(tmp_path) + fs = FakeStream("cam") + session.add(fs) + + session.connect() + session.start(countdown_s=0) + session.stop() + assert session.state is SessionState.CONNECTED + + # Second recording — no reconnect needed. + session.start(countdown_s=0) + assert session.state is SessionState.RECORDING + # Stream saw two recording cycles (two start+stop pairs). + assert fs.start_calls == 2 + session.stop() + assert fs.stop_calls == 2 + session.disconnect() + + def test_countdown_tick_callback_fires(self, tmp_path): + """The ``on_countdown_tick`` callback should fire for each second.""" + session = _session(tmp_path) + session.add(FakeStream("cam")) + session.connect() + + seen = [] + session.start( + countdown_s=3, + on_countdown_tick=lambda n: seen.append(n), + ) + # Ticks go 3 → 2 → 1 in descending order + assert seen == [3, 2, 1] + session.stop() + session.disconnect() + class TestStop: def test_stop_transitions_to_stopped(self, tmp_path): @@ -768,7 +969,7 @@ class TestSessionLog: def test_session_log_captures_state_transitions(self, tmp_path): session = _session(tmp_path) session.add(FakeStream("a")) - session.start() + session.start(countdown_s=0) session.stop() log_path = tmp_path / "session_log.jsonl" @@ -776,8 +977,15 @@ def test_session_log_captures_state_transitions(self, tmp_path): lines = [json.loads(l) for l in log_path.read_text().strip().split("\n")] transitions = [l for l in lines if l["kind"] == "state_transition"] edges = {(t["from"], t["to"]) for t in transitions} - assert ("idle", "preparing") in edges - assert ("preparing", "recording") in edges + # 0.2 four-phase lifecycle: IDLE → CONNECTING → CONNECTED → + # PREPARING → COUNTDOWN → RECORDING → STOPPING → STOPPED + # (the auto-connect path used by the legacy one-shot + # start()/stop() still lands in STOPPED at the end). + assert ("idle", "connecting") in edges + assert ("connecting", "connected") in edges + assert ("connected", "preparing") in edges + assert ("preparing", "countdown") in edges + assert ("countdown", "recording") in edges assert ("recording", "stopping") in edges assert ("stopping", "stopped") in edges @@ -785,7 +993,7 @@ def test_session_log_flushes_during_recording(self, tmp_path): """A crash between start() and stop() must leave a readable log.""" session = _session(tmp_path) session.add(FakeStream("a")) - session.start() + session.start(countdown_s=0) # Simulate "read the log while still RECORDING" content = (tmp_path / "session_log.jsonl").read_text() assert "preparing" in content diff --git a/tests/unit/test_stream.py b/tests/unit/test_stream.py index 8237f82..5fb4bbc 100644 --- a/tests/unit/test_stream.py +++ b/tests/unit/test_stream.py @@ -109,3 +109,32 @@ def test_stream_base_exposes_id_kind_capabilities(): assert demo.id == "sensor_42" assert demo.kind == "sensor" assert demo.capabilities.supports_precise_timestamps is True + + +class TestDeviceKey: + """device_key is the physical-device identity used for dedup.""" + + def test_default_is_none(self): + """StreamBase subclasses without hardware default to None.""" + assert _DemoStream("x").device_key is None + + def test_override_returns_tuple(self): + """Adapters can advertise a stable (adapter_type, device_id) tuple.""" + + class _UvcLike(StreamBase): + def __init__(self, id: str, idx: int) -> None: + super().__init__( + id=id, + kind="video", + capabilities=StreamCapabilities(), + ) + self._idx = idx + + @property + def device_key(self): + return ("uvc_webcam", str(self._idx)) + + assert _UvcLike("cam", 0).device_key == ("uvc_webcam", "0") + assert _UvcLike("cam", 1).device_key == ("uvc_webcam", "1") + # Same adapter, different indices → distinct keys. + assert _UvcLike("a", 0).device_key != _UvcLike("b", 1).device_key From 3702b5381e53bf5df868e7d7062beb1e48f1cf02 Mon Sep 17 00:00:00 2001 From: styu12 Date: Thu, 9 Apr 2026 15:39:17 -0700 Subject: [PATCH 15/45] docs(replay): design spec for sf.replay.launch() session viewer Record the brainstormed design for the local browser-based replay viewer that lets external customers open a saved session folder and verify sync quality (Before/After toggle + per-stream offset report) without standing up the internal egonaut/web dashboard. Key decisions captured: - separate top-level subpackage syncfield.replay (mirrors viewer) - Starlette + uvicorn behind a [replay] extra - React/Vite/Tailwind SPA ported from egonaut/web's DataReviewPage - pre-built static/ committed so end users need no Node toolchain - 3D viewers and i18n explicitly out of v1 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-09-replay-viewer-design.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-replay-viewer-design.md diff --git a/docs/superpowers/specs/2026-04-09-replay-viewer-design.md b/docs/superpowers/specs/2026-04-09-replay-viewer-design.md new file mode 100644 index 0000000..5bba086 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-replay-viewer-design.md @@ -0,0 +1,423 @@ +# Replay Viewer — Design Spec + +**Date:** 2026-04-09 +**Status:** Approved for implementation +**Owner:** styu12 + +## Problem + +SyncField SDK records multi-modal sessions to a local folder, then the +synced result (per-stream offsets, quality report) comes back from +`sync.opengraphlabs.com`. Right now there is no way for an external +customer to **verify that the sync worked** without standing up the +internal egonaut/web dashboard, which depends on Supabase, auth, and +hosted infrastructure they don't have. + +We need a **local, zero-infra** way to open a saved session and visually +confirm sync quality — primarily by playing the streams back together +and comparing the **before** (raw recording) and **after** (synced) +states side by side. + +## Scope + +**In v1 (scope A):** +- Open a local session folder in a browser-based replay viewer +- Show synced multi-stream video playback with a master scrubber +- Toggle between **Before** (raw) and **After** (synced) at any time +- Show a sync report panel: per-stream offset, confidence, quality badge +- Show generic sensor streams as minimal, clean line/area charts +- Read sync result from `synced/sync_report.json` if present + +**Out of v1 (deferred):** +- 3D viewers (egomotion, body pose) and `@react-three/*` deps +- Action segments / subtitles +- Hand pose / contact overlays beyond what tactile streams already produce +- i18n (English-only, hardcoded strings) +- Pipeline result visualization +- `launch_passive()` variant +- Multi-session browser ("pick a folder" UI) +- LocalStorage persistence of UI state +- Browser tab close → auto-shutdown detection +- E2E browser tests + +## Architecture + +``` +sf.replay.launch(session_dir) + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ syncfield.replay (Python, [replay] extra) │ +│ │ +│ loader.py → ReplayManifest (manifest + │ +│ sync_point + sync_report) │ +│ server.py → Starlette + uvicorn, 127.0.0.1 │ +│ static/ → built React SPA (committed) │ +└────────────────────┬────────────────────────────┘ + │ HTTP + ▼ +┌─────────────────────────────────────────────────┐ +│ Browser SPA (React + Vite + Tailwind 4) │ +│ │ +│ - DataReviewPage shell (ported from egonaut) │ +│ - VideoArea + per-stream