Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions src/syncfield/adapters/_generic.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,96 @@
"""Private internals shared by PollingSensorStream and PushSensorStream."""
"""Private internals shared by PollingSensorStream and PushSensorStream.

SDK contract for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

3. **Transient transport hiccup auto-reopen.** Adapters that own a transport
(serial port, BLE connection, USB pipe) MUST attempt up to
:data:`TRANSIENT_REOPEN_MAX_ATTEMPTS` reopens with exponential backoff
(total wall time ≤ :data:`TRANSIENT_REOPEN_MAX_WAIT_S` seconds) before
surfacing a stream-level error. Use :func:`retry_open` in the adapter's
open / reconnect path to satisfy this contract automatically.
"""

from __future__ import annotations

import logging
import time
import threading
from typing import Optional
from typing import Callable, Optional, TypeVar

from syncfield.types import StreamCapabilities

logger = logging.getLogger(__name__)

#: Maximum number of reopen attempts before giving up (Contract 3).
TRANSIENT_REOPEN_MAX_ATTEMPTS: int = 5

#: Maximum total backoff wait across all retry attempts in seconds (Contract 3).
TRANSIENT_REOPEN_MAX_WAIT_S: float = 30.0

_T = TypeVar("_T")


def retry_open(
open_fn: Callable[[], _T],
*,
max_attempts: int = TRANSIENT_REOPEN_MAX_ATTEMPTS,
max_wait_s: float = TRANSIENT_REOPEN_MAX_WAIT_S,
stream_id: str = "<unknown>",
) -> _T:
"""Call *open_fn* up to *max_attempts* times with exponential backoff.

**SDK contract — transient transport reopen (Contract 3):** Adapters
that own a transport (serial, BLE, USB) MUST use this helper (or
equivalent retry logic) in their open/reconnect path. A single
transient ``OSError`` or ``SerialException`` MUST NOT surface
immediately as a stream-level failure. The adapter MUST retry at
least :data:`TRANSIENT_REOPEN_MAX_ATTEMPTS` times, sleeping 1 s,
2 s, 4 s, … (capped so total wait ≤ *max_wait_s*) between attempts.

Args:
open_fn: Zero-argument callable that opens the transport and
returns a handle (e.g. a ``serial.Serial`` instance).
May raise any exception on transient failure.
max_attempts: How many attempts before re-raising. Defaults to
:data:`TRANSIENT_REOPEN_MAX_ATTEMPTS` (5).
max_wait_s: Hard cap on total sleep time across all retries.
Defaults to :data:`TRANSIENT_REOPEN_MAX_WAIT_S` (30 s).
stream_id: Stream identifier for log messages.

Returns:
The value returned by *open_fn* on success.

Raises:
Exception: The last exception raised by *open_fn* once all
attempts are exhausted.
"""
delay = 1.0
last_exc: Optional[Exception] = None
total_waited = 0.0
for attempt in range(1, max_attempts + 1):
try:
return open_fn()
except Exception as exc:
last_exc = exc
if attempt >= max_attempts:
break
# Compute sleep duration capped by max_wait_s budget.
# Even when the budget is exhausted we still retry up to
# max_attempts times — max_wait_s limits sleep duration only,
# not the number of attempts.
remaining_budget = max_wait_s - total_waited
wait = min(delay, max(remaining_budget, 0.0))
logger.warning(
"[%s] transient open error (attempt %d/%d): %s — retrying in %.1fs",
stream_id, attempt, max_attempts, exc, wait,
)
if wait > 0:
time.sleep(wait)
total_waited += wait
delay = delay * 2
assert last_exc is not None
raise last_exc


class _SensorWriteCore:
"""Frame counter and timing tracker for generic sensor helpers.
Expand Down
38 changes: 35 additions & 3 deletions src/syncfield/adapters/polling_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
"""PollingSensorStream — generic helper for sensors with a read() function."""
"""PollingSensorStream — generic helper for sensors with a read() function.

SDK contract for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

3. **Transient transport hiccup auto-reopen.** When an ``open`` callback is
provided, ``PollingSensorStream`` MUST attempt up to
:data:`~syncfield.adapters._generic.TRANSIENT_REOPEN_MAX_ATTEMPTS` reopens
with exponential backoff before surfacing a stream-level error. This is
implemented via :func:`~syncfield.adapters._generic.retry_open`.
"""

from __future__ import annotations

Expand All @@ -10,6 +19,7 @@
from syncfield.adapters._generic import (
_SensorWriteCore,
_resolve_capabilities,
retry_open,
)
from syncfield.clock import SessionClock
from syncfield.stream import DeviceKey, StreamBase
Expand All @@ -24,7 +34,19 @@


class PollingSensorStream(StreamBase):
"""Generic helper that polls a user read() function on a fixed hz."""
"""Generic helper that polls a user read() function on a fixed hz.

SDK contract for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

3. **Transient transport hiccup auto-reopen.** When an ``open`` callback
is supplied, ``PollingSensorStream.connect()`` MUST attempt up to
:data:`~syncfield.adapters._generic.TRANSIENT_REOPEN_MAX_ATTEMPTS` (5)
reopens with exponential backoff (≤ 30 s total) before surfacing a
stream-level error. A single ``OSError`` or ``SerialException`` MUST
NOT propagate immediately — the adapter retries automatically so that
brief USB re-enumerations or connection blips are transparent to GUI
users.
"""

def __init__(
self,
Expand Down Expand Up @@ -173,8 +195,18 @@ def _capture_loop(self) -> None:
# ------------------------------------------------------------------

def connect(self) -> None:
"""Open the transport and start the polling loop.

**SDK contract — transient transport reopen (Contract 3):**
When an ``open`` callback was provided, this method MUST retry
the open up to :data:`~syncfield.adapters._generic.TRANSIENT_REOPEN_MAX_ATTEMPTS`
times with exponential backoff before propagating an exception.
"""
if self._open is not None:
self._handle = self._open()
self._handle = retry_open(
self._open,
stream_id=self.id,
)
self._stop_event.clear()
self._thread = threading.Thread(
target=self._capture_loop,
Expand Down
93 changes: 89 additions & 4 deletions src/syncfield/adapters/push_sensor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
"""PushSensorStream — generic helper for callback/asyncio/external-thread sources."""
"""PushSensorStream — generic helper for callback/asyncio/external-thread sources.

SDK contracts for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

1. **on_connect callback is non-blocking.** ``PushSensorStream`` MUST NOT
synchronously wait on the callback the user supplied via ``on_connect=``.
The SDK fires it in a background daemon thread; the calling thread (and
the orchestrator's connect loop) return immediately. If the user's
callback itself blocks, that blocks ONLY the callback's background thread,
NOT the stream lifecycle or the viewer's "Connecting…" indicator.

2. **Burst-aware capture_ns interpolation.** When the user's producer reads
N > 1 samples in a single USB/BLE tick, it MUST NOT pass the same
``capture_ns`` to all N ``push()`` calls. Instead it SHOULD call
:func:`burst_timestamps` to obtain per-sample host-monotonic timestamps
spaced by ``dt = 1e9 / expected_hz`` nanoseconds, anchored so the *last*
sample lands at the actual read instant.
"""

from __future__ import annotations

import threading
import time
from typing import Callable, Optional
from typing import Callable, List, Optional

from syncfield.adapters._generic import _SensorWriteCore, _resolve_capabilities
from syncfield.clock import SessionClock
Expand All @@ -15,8 +32,62 @@
)


def burst_timestamps(n: int, *, anchor_ns: Optional[int] = None, expected_hz: float) -> List[int]:
"""Compute per-sample host-monotonic timestamps for a burst read.

When a single USB/BLE read returns *n* samples that were collected at a
known fixed rate, this helper distributes timestamps so the **last**
sample lands at *anchor_ns* (or ``time.monotonic_ns()`` if omitted) and
earlier samples step backward by ``1e9 / expected_hz`` nanoseconds.

The SDK contract for ``PushSensorStream`` requires callers to use this
helper (or equivalent arithmetic) rather than passing the same
``capture_ns`` to all ``push()`` calls in a burst. Clustering N samples
at one tick degrades timestamp quality at high rates (1 kHz+) and defeats
the sync alignment that depends on per-sample spread.

Args:
n: Number of samples in the burst. Must be >= 1.
anchor_ns: Host monotonic nanosecond timestamp for the *last* sample
in the burst. Defaults to ``time.monotonic_ns()`` at call time.
expected_hz: Expected sensor sample rate in Hz.

Returns:
A list of *n* integer nanosecond timestamps in ascending order, with
``timestamps[-1] == anchor_ns`` and adjacent deltas equal to
``round(1e9 / expected_hz)``.

Example::

ts = burst_timestamps(5, anchor_ns=recv_ns, expected_hz=1000.0)
for i, (sample, capture_ns) in enumerate(zip(burst, ts)):
stream.push(sample, capture_ns=capture_ns)
"""
if n < 1:
raise ValueError(f"burst_timestamps: n must be >= 1, got {n}")
if expected_hz <= 0:
raise ValueError(f"burst_timestamps: expected_hz must be > 0, got {expected_hz}")
if anchor_ns is None:
anchor_ns = time.monotonic_ns()
dt_ns = round(1e9 / expected_hz)
return [anchor_ns - (n - 1 - i) * dt_ns for i in range(n)]


class PushSensorStream(StreamBase):
"""Generic helper for sensors driven by user-owned producer threads."""
"""Generic helper for sensors driven by user-owned producer threads.

SDK contracts for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

1. **on_connect callback is non-blocking.** The SDK fires the callback in
a background daemon thread so the orchestrator's connect loop MUST NOT
be blocked even if the user's ``on_connect`` coroutine/function takes
time to complete. See :func:`burst_timestamps` for Contract 2.

2. **Burst-aware capture_ns interpolation.** Callers who receive N > 1
samples per USB/BLE tick MUST distribute timestamps using
:func:`burst_timestamps` rather than passing the same ``capture_ns``
to every ``push()`` in a burst.
"""

def __init__(
self,
Expand Down Expand Up @@ -49,9 +120,23 @@ def device_key(self) -> Optional[DeviceKey]:
# ------------------------------------------------------------------

def connect(self) -> None:
"""Open the stream for pushing.

**SDK contract — non-blocking on_connect:** The user-supplied
``on_connect`` callback MUST NOT block the calling thread. The
SDK fires it in a background daemon thread so the orchestrator's
connect loop (and the GUI's "Connecting…" indicator) proceed
immediately regardless of how long the callback takes.
"""
self._connected = True
if self._on_connect is not None:
self._on_connect(self)
t = threading.Thread(
target=self._on_connect,
args=(self,),
name=f"push-sensor-on-connect-{self.id}",
daemon=True,
)
t.start()

def start_recording(self, session_clock: SessionClock) -> None:
self._begin_recording_window(session_clock)
Expand Down
41 changes: 41 additions & 0 deletions src/syncfield/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,26 @@ class SessionOrchestrator:
require an audio stream — disabling this flag in a
multi-host session means you must add your own
audio-capable stream explicitly.

SDK contracts for GUI consumers (see syncfield-sensor-onboarding-enhancements §5):

4. **SyncToneConfig.silent() MUST NOT register a host_audio stream.**
When the session's ``sync_tone`` has ``suppress_host_audio=True``
(set automatically by :meth:`~syncfield.tone.SyncToneConfig.silent`),
:meth:`_maybe_preregister_host_audio` and :meth:`_maybe_inject_host_audio`
MUST return immediately without registering or connecting any
:class:`~syncfield.adapters.host_audio.HostAudioStream`.

5. **Stream errors MUST NOT propagate to the SessionOrchestrator.**
An unhandled exception inside a stream's capture loop (i.e. inside
the adapter's background thread that calls ``push()`` or runs the
polling loop) MUST NOT raise through the orchestrator or cause the
session to transition out of ``RECORDING``. Each stream runs in
its own daemon thread — errors are isolated per stream. The session
continues collecting data from all remaining healthy streams.
Errors raised during ``start_recording`` fan-out are handled by the
orchestrator's rollback logic but do NOT affect other streams
mid-session.
"""

def __init__(
Expand Down Expand Up @@ -2753,9 +2773,21 @@ def _maybe_preregister_host_audio(self) -> None:
registered. Only registers the stream (no device open) so the
viewer can display the audio card immediately. The actual device
connection happens in :meth:`connect` along with all other streams.

**SDK contract (Contract 4):** When the session's
:class:`~syncfield.tone.SyncToneConfig` has
``suppress_host_audio=True`` (set automatically by
:meth:`~syncfield.tone.SyncToneConfig.silent`), this method MUST
skip host-audio registration so that a silent-mode session never
shows a ghost ``host_audio`` stream in the GUI.
"""
if not self._enable_host_audio:
return
# Contract 4: SyncToneConfig.silent() sets suppress_host_audio=True
# to signal that no acoustic sync path is desired. Honour it here so
# a ghost host_audio stream never appears in the GUI viewer.
if self._sync_tone.suppress_host_audio:
return

try:
from syncfield.adapters.host_audio import (
Expand Down Expand Up @@ -2784,9 +2816,18 @@ def _maybe_inject_host_audio(self) -> None:
this is a no-op (connect loop handles it). If not yet added
(e.g. user skipped add() and went straight to connect()), this
adds and connects it now.

**SDK contract (Contract 4):** Mirrors the ``suppress_host_audio``
check in :meth:`_maybe_preregister_host_audio` — both guards MUST
be present to prevent late injection during the connect phase.
"""
if not self._enable_host_audio:
return
# Contract 4: honour SyncToneConfig.silent()'s suppress_host_audio
# flag at connect() time as well, in case the caller bypassed add()
# and went straight to connect() (e.g. legacy one-shot path).
if self._sync_tone.suppress_host_audio:
return

has_audio = any(
s.capabilities.provides_audio_track
Expand Down
Loading
Loading