diff --git a/examples/meta_quest/record.py b/examples/meta_quest/record.py
index c8e8091..c089bb9 100644
--- a/examples/meta_quest/record.py
+++ b/examples/meta_quest/record.py
@@ -66,6 +66,7 @@
session.add(MetaQuestHandStream(
"quest_tracking",
mode="hand", # or "controller" to map Touch Plus pose into wrist slots
+ quest_host=QUEST_IP, # push our IP via Quest HTTP — no broadcast needed
))
# Stereo passthrough camera. We pull MP4 + timestamps over HTTP on
diff --git a/src/syncfield/adapters/meta_quest.py b/src/syncfield/adapters/meta_quest.py
index 176fec7..f34f6da 100644
--- a/src/syncfield/adapters/meta_quest.py
+++ b/src/syncfield/adapters/meta_quest.py
@@ -137,6 +137,12 @@ class MetaQuestHandStream(StreamBase):
_discovery_kind = "sensor"
_discovery_adapter_type = "meta_quest"
+ # Quest companion app HTTP control port (same CameraHttpServer that
+ # serves /status, /preview, /recording/*). We piggy-back on it to
+ # push the Mac's IP directly to the tracker, skipping UDP broadcast
+ # discovery entirely (which some APs silently drop).
+ DEFAULT_QUEST_HTTP_PORT = 14045
+
def __init__(
self,
id: str,
@@ -144,6 +150,8 @@ def __init__(
host: str = "0.0.0.0",
port: int = DEFAULT_PORT,
mode: str = "hand",
+ quest_host: Optional[str] = None,
+ quest_http_port: int = 14045,
) -> None:
super().__init__(
id=id,
@@ -158,6 +166,8 @@ def __init__(
self._host = host
self._port = port
self._mode = mode.lower() if mode in ("hand", "controller") else "hand"
+ self._quest_host = quest_host
+ self._quest_http_port = quest_http_port
self._socket: Optional[socket.socket] = None
self._receive_thread: Optional[threading.Thread] = None
@@ -212,6 +222,20 @@ def connect(self) -> None:
self.id, self._host, self._port, self._mode,
)
+ # Direct-push of our IP to the Quest companion app — bypasses
+ # UDP broadcast discovery, which some APs silently drop. Only
+ # runs when the caller passed quest_host (i.e. they know where
+ # the headset is); otherwise we fall back to the responder path.
+ if self._quest_host:
+ try:
+ self._push_target_to_quest()
+ except Exception as exc:
+ logger.warning(
+ "[%s] Could not push target IP to Quest at %s:%d: %s "
+ "(falling back to broadcast discovery)",
+ self.id, self._quest_host, self._quest_http_port, exc,
+ )
+
# Spin up the discovery responder so the Quest companion app can
# auto-resolve our IP. Without this the user has to type the
# Mac's IP into the Quest HUD every time the network changes.
@@ -262,6 +286,34 @@ def disconnect(self) -> None:
# Discovery responder (UDP :14044)
# ------------------------------------------------------------------
+ def _push_target_to_quest(self) -> None:
+ """POST our local IP to the Quest's CameraHttpServer.
+
+ The Quest's UDPTrackingSender will then unicast tracking packets
+ directly to us — no UDP broadcast required. This is the reliable
+ path when the Wi-Fi AP drops broadcasts (client isolation,
+ IPv6-only, etc.).
+
+ Uses urllib so we don't pull httpx into the base sensor adapter
+ just for one request — the camera adapter has its own httpx
+ dependency, but MetaQuestHandStream stays import-light.
+ """
+ import urllib.request
+
+ local_ip = self._resolve_local_ip_for(self._quest_host)
+ payload = json.dumps({"ip": local_ip, "port": self._port}).encode("utf-8")
+ url = f"http://{self._quest_host}:{self._quest_http_port}/tracker/target"
+ req = urllib.request.Request(
+ url, data=payload, method="POST",
+ headers={"Content-Type": "application/json"},
+ )
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
+ body = resp.read().decode("utf-8", errors="replace")
+ logger.info(
+ "[%s] Pushed target %s:%d to Quest %s — %s",
+ self.id, local_ip, self._port, self._quest_host, body.strip(),
+ )
+
def _start_discovery_responder(self) -> None:
"""Respond to Quest companion-app discovery broadcasts.
@@ -489,20 +541,26 @@ def _process_packet(self, data: bytes) -> None:
# Build channels from packet
channels = self._parse_channels(packet)
+ # Always emit so the viewer's live panel shows tracking data the
+ # moment the headset is connected — without this the user has to
+ # press Record before they can confirm the sensor is even alive.
+ # Recording state still gates first/last_at (those describe the
+ # recorded segment, not the live preview) and the FinalizationReport
+ # frame count.
+ frame_number = self._frame_count
+ self._frame_count += 1
if self._recording:
if self._first_at is None:
self._first_at = capture_ns
self._last_at = capture_ns
- frame_number = self._frame_count
- self._frame_count += 1
- self._emit_sample(SampleEvent(
- stream_id=self.id,
- frame_number=frame_number,
- capture_ns=capture_ns,
- channels=channels,
- uncertainty_ns=self.UNCERTAINTY_NS,
- clock_domain=self.CLOCK_DOMAIN,
- ))
+ self._emit_sample(SampleEvent(
+ stream_id=self.id,
+ frame_number=frame_number,
+ capture_ns=capture_ns,
+ channels=channels,
+ uncertainty_ns=self.UNCERTAINTY_NS,
+ clock_domain=self.CLOCK_DOMAIN,
+ ))
def _update_connection_state_on_packet(self, at_ns: int) -> None:
"""Emit HEARTBEAT on first packet, RECONNECT after a drop."""
diff --git a/src/syncfield/adapters/meta_quest_camera/preview.py b/src/syncfield/adapters/meta_quest_camera/preview.py
index c51ca63..4fd4a7f 100644
--- a/src/syncfield/adapters/meta_quest_camera/preview.py
+++ b/src/syncfield/adapters/meta_quest_camera/preview.py
@@ -132,16 +132,40 @@ def stop(self) -> None:
# ------------------------------------------------------------------
+ # Exponential backoff for repeat failures. Quest's HTTP server
+ # disappearing for a minute (app restart, idle screen-off) would
+ # otherwise spam ~60 WARN lines at 1 s cadence.
+ _RECONNECT_INITIAL_S = 1.0
+ _RECONNECT_MAX_S = 8.0
+
def _run(self) -> None:
+ delay_s = self._RECONNECT_INITIAL_S
+ consecutive_failures = 0
while not self._stop_event.is_set():
try:
self._consume_once()
+ # _consume_once returned without raising — either the
+ # stream ended cleanly or the stop_event fired. Reset
+ # the backoff so the next genuine failure logs loudly.
+ delay_s = self._RECONNECT_INITIAL_S
+ consecutive_failures = 0
except Exception as exc: # pragma: no cover - exercised by reconnect test
- logger.warning("MJPEG consumer error: %s", exc)
- if self._on_health is not None:
+ consecutive_failures += 1
+ # First failure per reconnect cycle is a real warning;
+ # subsequent retries against the same outage get demoted
+ # to INFO so debug sessions aren't buried under spam.
+ if consecutive_failures == 1:
+ logger.warning("MJPEG consumer error: %s", exc)
+ else:
+ logger.info(
+ "MJPEG consumer error (retry %d, next in %.1fs): %s",
+ consecutive_failures, delay_s, exc,
+ )
+ if self._on_health is not None and consecutive_failures == 1:
self._on_health("drop", f"mjpeg error: {exc}")
- if self._stop_event.wait(1.0):
+ if self._stop_event.wait(delay_s):
return
+ delay_s = min(delay_s * 2.0, self._RECONNECT_MAX_S)
def _consume_once(self) -> None:
client = httpx.Client(transport=self._transport, timeout=None)
@@ -218,6 +242,10 @@ def _decode_jpeg(data: bytes):
from PIL import Image
img = Image.open(io.BytesIO(data)).convert("RGB")
+ # Quest's passthrough camera frames arrive Y-flipped (OpenGL
+ # texture-origin convention): without this transpose the viewer
+ # shows the scene upside-down. Flip before the numpy handoff.
+ img = img.transpose(Image.FLIP_TOP_BOTTOM)
# The viewer server re-encodes frames assuming BGR (SyncField's
# house convention across OakCameraStream and UVCWebcamStream),
# so flip the last axis here.
diff --git a/src/syncfield/adapters/meta_quest_camera/stream.py b/src/syncfield/adapters/meta_quest_camera/stream.py
index 8173d60..65b60a0 100644
--- a/src/syncfield/adapters/meta_quest_camera/stream.py
+++ b/src/syncfield/adapters/meta_quest_camera/stream.py
@@ -267,18 +267,26 @@ def latest_frame_right(self):
@property
def latest_frame(self):
- """Viewer-compat: return the left eye so the standard video panel
- has something to render. The viewer's ``StreamSnapshot`` polls
- ``stream.latest_frame`` for any adapter declaring
- ``kind="video"``; without this proxy the camera card would sit
- black even while the MJPEG preview is actively streaming.
-
- The left eye is authoritative for now — phase 1 records the
- same primary-camera view on both slots (see spec §9 Q1). Once
- per-eye acquisition lands we can switch this to a side-by-side
- composite or expose a user-selectable eye.
+ """Viewer-compat: return a side-by-side ``[left | right]`` composite
+ so the single video panel shows both eyes at once. The viewer's
+ ``StreamSnapshot`` polls ``stream.latest_frame`` for any adapter
+ declaring ``kind="video"``; syncfield's panel model is 1 panel =
+ 1 stream_id, so until we split into two adapters we surface the
+ stereo pair as a horizontally-concatenated frame.
+
+ Falls back to whichever eye is available if the other is still
+ connecting or has dropped — users should see *something* rather
+ than a black card whenever at least one preview is alive.
"""
left = self.latest_frame_left
+ right = self.latest_frame_right
+ if left is not None and right is not None:
+ import numpy as np
+ if left.shape == right.shape:
+ return np.hstack((left, right))
+ # Shapes can diverge for a frame or two during startup while
+ # the two previews race to produce their first decoded image.
+ # Fall through to the single-eye path instead of raising.
if left is not None:
return left
- return self.latest_frame_right
+ return right
diff --git a/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx b/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx
index b815b2d..f7ee21b 100644
--- a/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx
+++ b/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx
@@ -1,3 +1,4 @@
+///