diff --git a/examples/iphone_mac_webcam/record.py b/examples/iphone_mac_webcam/record.py index e70c44e..2ee6d0a 100644 --- a/examples/iphone_mac_webcam/record.py +++ b/examples/iphone_mac_webcam/record.py @@ -31,7 +31,7 @@ import syncfield as sf import syncfield.viewer -from syncfield.adapters import BLEImuGenericStream, Go3SStream, UVCWebcamStream +from syncfield.adapters import BLEImuGenericStream, UVCWebcamStream from syncfield.adapters.ble_imu_profiles import WIT_WT901BLE_200HZ # Resolved by BleakScanner on 2026-04-14; replace with the address of YOUR Go3S. @@ -46,22 +46,9 @@ ) session.add(UVCWebcamStream("mac_webcam", device_index=0, output_dir=session.output_dir)) session.add(UVCWebcamStream("iphone", device_index=1, output_dir=session.output_dir)) -# Both WT901BLE units advertise the same name ("WT901BLE68"), so they must be -# distinguished by address. Resolved via active-scan on 2026-04-14. -session.add(BLEImuGenericStream( - "wrist_left_imu", - profile=WIT_WT901BLE_200HZ, - address="5622CCC4-A621-96DC-A7B5-E7650370E8A3", -)) -session.add(BLEImuGenericStream( - "wrist_right_imu", - profile=WIT_WT901BLE_200HZ, - address="6E22ED0E-72CD-0175-6F29-0BA8D502CBAB", -)) -session.add(Go3SStream( - "go3s_overhead", - ble_address=GO3S_ADDRESS, - output_dir=session.output_dir, -)) +session.add(BLEImuGenericStream("wrist_left_imu", profile=WIT_WT901BLE_200HZ, address="5622CCC4-A621-96DC-A7B5-E7650370E8A3")) +session.add(BLEImuGenericStream("wrist_right_imu", profile=WIT_WT901BLE_200HZ, address="6E22ED0E-72CD-0175-6F29-0BA8D502CBAB")) +session.add(BLEImuGenericStream("elbow_left_imu", profile=WIT_WT901BLE_200HZ, address="1CD2DCDE-CE20-905E-7D66-66E20FB01AB6")) +session.add(BLEImuGenericStream("elbow_right_imu", profile=WIT_WT901BLE_200HZ, address="C7CA16B4-AFF6-CC54-C657-83836E96979A")) syncfield.viewer.launch(session) diff --git a/examples/meta_quest/record.py b/examples/meta_quest/record.py index 35d3af3..07bc2d1 100644 --- a/examples/meta_quest/record.py +++ b/examples/meta_quest/record.py @@ -14,17 +14,18 @@ MetaQuestHandStream, ) from syncfield.adapters.ble_imu_profiles import WIT_WT901BLE_200HZ +from syncfield.adapters.meta_quest import discover_quest_ip -QUEST_IP = "192.168.4.26" +quest_ip = discover_quest_ip() or exit("Quest not found — is the SyncField Quest Sender app running?") session = sf.SessionOrchestrator( - host_id="mac_studio", + host_id="mac", output_dir=Path(__file__).parent / "output", ) out = session.output_dir -session.add(MetaQuestHandStream("quest_tracking", quest_host=QUEST_IP)) -session.add(MetaQuestCameraStream("quest_cam", quest_host=QUEST_IP, output_dir=out)) +session.add(MetaQuestHandStream("quest_tracking", quest_host=quest_ip)) +session.add(MetaQuestCameraStream("quest_cam", quest_host=quest_ip, output_dir=out)) session.add(BLEImuGenericStream("wrist_left_imu", profile=WIT_WT901BLE_200HZ, address="5622CCC4-A621-96DC-A7B5-E7650370E8A3")) session.add(BLEImuGenericStream("wrist_right_imu", profile=WIT_WT901BLE_200HZ, address="6E22ED0E-72CD-0175-6F29-0BA8D502CBAB")) session.add(BLEImuGenericStream("elbow_left_imu", profile=WIT_WT901BLE_200HZ, address="1CD2DCDE-CE20-905E-7D66-66E20FB01AB6")) diff --git a/pyproject.toml b/pyproject.toml index 076f135..5aee885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ camera = [ "bleak>=0.21", "aiohttp>=3.9", "httpx>=0.25.0", + "zeroconf>=0.130", ] all = [ "sounddevice>=0.4.6", diff --git a/src/syncfield/adapters/meta_quest.py b/src/syncfield/adapters/meta_quest.py index bdb4458..85e47aa 100644 --- a/src/syncfield/adapters/meta_quest.py +++ b/src/syncfield/adapters/meta_quest.py @@ -81,6 +81,71 @@ _QUEST_HTTP_PORT = 14045 _QUEST_IP_CACHE_PATH = Path.home() / ".cache" / "syncfield" / "quest_ip" +# mDNS service type the Quest companion app advertises. Matches the +# registerService() call in opengraph-studio's QuestMdnsAdvertiser.cs. +_MDNS_SERVICE_TYPE = "_syncfield-quest._tcp.local." + + +def _mdns_discover(timeout_s: float) -> Optional[str]: + """Browse local mDNS for a Quest sender and return its IP. + + Relies on the Unity app's :file:`QuestMdnsAdvertiser.cs` component + registering an ``_syncfield-quest._tcp`` service via Android + ``NsdManager``. mDNS multicast crosses most home / office networks + that filter UDP broadcasts (the protocol Apple uses for AirPlay / + AirPrint) so this is the robust path — broadcast listen stays as + a fallback for networks with multicast disabled too. + + Returns ``None`` when ``zeroconf`` isn't installed, no service + appears within the timeout, or the resolver hands back an address + that doesn't look like an IPv4. + """ + try: + from zeroconf import ServiceBrowser, ServiceListener, Zeroconf + except ImportError: + logger.info( + "discover_quest_ip: zeroconf not installed — skipping mDNS. " + "Install with `pip install syncfield[camera]` for mDNS support." + ) + return None + + import threading as _threading + + found_event = _threading.Event() + found_ip: Dict[str, Optional[str]] = {"ip": None} + + class _Listener(ServiceListener): + def add_service(self, zc, type_, name): # noqa: D401 + info = zc.get_service_info(type_, name, timeout=1500) + if info is None or not info.addresses: + return + # addresses is a list of packed IPv4/IPv6; take the first + # routable IPv4 we see. + for raw in info.addresses: + if len(raw) == 4: + ip = socket.inet_ntoa(raw) + logger.info("discover_quest_ip: mDNS found Quest at %s", ip) + found_ip["ip"] = ip + found_event.set() + return + + def update_service(self, zc, type_, name): pass + def remove_service(self, zc, type_, name): pass + + zc = Zeroconf() + try: + ServiceBrowser(zc, _MDNS_SERVICE_TYPE, _Listener()) + found_event.wait(timeout=timeout_s) + return found_ip["ip"] + except Exception as exc: # noqa: BLE001 + logger.warning("discover_quest_ip: mDNS error: %s", exc) + return None + finally: + try: + zc.close() + except Exception: + pass + def _probe_quest_alive(ip: str, timeout_s: float = 1.5) -> bool: """Cheap reachability check — does the Quest companion HTTP server @@ -115,26 +180,27 @@ def _write_cached_quest_ip(ip: str) -> None: def discover_quest_ip(timeout_s: float = 8.0) -> Optional[str]: - """Resolve the Quest's IPv4 with cache + broadcast fallback. - - Resolution order: - - 1. ``QUEST_IP`` env var — verbatim, no validation. Use this to pin - a specific headset (multi-Quest setups, manual override, CI). - 2. Cached IP from ``~/.cache/syncfield/quest_ip`` — last address - that worked. Validated by a 1.5 s ``/status`` probe so a stale - cache after a DHCP lease change just falls through. - 3. UDP :data:`DEFAULT_DISCOVERY_PORT` broadcast listener — picks - up the Quest companion app's :data:`DISCOVERY_PROBE` (sent - every ~2 s). Required when the cache is missing or stale. - Fails on networks that drop limited broadcasts (some APs with - client isolation enabled) — the cache exists precisely so - you only have to survive this once. - - On success the result is written back to the cache so the next - run resolves in <2 s without touching the network. Returns - ``None`` if everything fails; caller should print an actionable - error pointing at the env-var override. + """Resolve the Quest's IPv4 via mDNS with layered fallbacks. + + Resolution order — first non-``None`` result wins: + + 1. ``QUEST_IP`` env var — verbatim override for multi-Quest setups + / CI / the rare network that filters both mDNS AND broadcast. + 2. **mDNS browse for ``_syncfield-quest._tcp``** (default ~3 s of + the budget). The Quest companion app advertises this service + on start via Android ``NsdManager``. Works on any network that + passes multicast DNS (most home / office WiFi — same protocol + AirPlay, AirPrint, Chromecast rely on). + 3. Cached IP from ``~/.cache/syncfield/quest_ip``, validated by a + 1.5 s ``/status`` probe. Stale caches after a DHCP lease + change fall through naturally. + 4. UDP ``:14044`` broadcast listener — picks up the Quest's own + ``SYNCFIELD_DISCOVER_RECORDER_V1`` probe. Last resort for + networks that block mDNS but pass UDP broadcast. + + On success the result is cached so subsequent runs re-resolve in + <2 s. Returns ``None`` when every path fails; caller should print + an actionable error pointing at the env-var override. """ import os @@ -143,16 +209,29 @@ def discover_quest_ip(timeout_s: float = 8.0) -> Optional[str]: logger.info("discover_quest_ip: using QUEST_IP env override = %s", env_ip) return env_ip + # Cache first: on a stable network the Quest usually keeps the + # same DHCP lease and an HTTP probe (~1.5 s worst case) is cheap. + # mDNS, by contrast, takes ~5 s of wall-clock on macOS because + # zeroconf shares port 5353 with mDNSResponder and needs a warm-up. cached = _read_cached_quest_ip() if cached and _probe_quest_alive(cached): logger.info("discover_quest_ip: cached IP %s is alive", cached) return cached if cached: logger.info( - "discover_quest_ip: cached IP %s did not answer; falling back to broadcast", + "discover_quest_ip: cached IP %s did not answer; falling back to mDNS", cached, ) + # Fresh network / DHCP renewal / first run: ask mDNS. The Quest + # app advertises ``_syncfield-quest._tcp`` via Android NsdManager + # so any subnet that passes multicast DNS returns the answer + # within ~5 s. + mdns_ip = _mdns_discover(6.0) + if mdns_ip: + _write_cached_quest_ip(mdns_ip) + return mdns_ip + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) diff --git a/uv.lock b/uv.lock index 052e20a..0ecaffb 100644 --- a/uv.lock +++ b/uv.lock @@ -2369,6 +2369,7 @@ camera = [ { 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 = "httpx" }, + { name = "zeroconf" }, ] multihost = [ { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -2439,6 +2440,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], marker = "extra == 'multihost'", specifier = ">=0.24.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'viewer'", specifier = ">=0.24.0" }, { name = "zeroconf", marker = "extra == 'all'", specifier = ">=0.130" }, + { name = "zeroconf", marker = "extra == 'camera'", specifier = ">=0.130" }, { name = "zeroconf", marker = "extra == 'multihost'", specifier = ">=0.130" }, ] provides-extras = ["audio", "uvc", "ble", "oak", "viewer", "multihost", "camera", "all"]