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
27 changes: 21 additions & 6 deletions examples/egocap/capture_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def start(
*,
sources: Sequence[SourceType],
cameras: Sequence[CameraSelection] = (),
plugins: Sequence[PluginType] = (),
plugins: Sequence[PluginType | ManusPluginConfig] = (),
on_started: Callback[CaptureStarted] | None = None,
on_status: Callback[CaptureStatus] | None = None,
on_capture_frame: Callback[CaptureFrame] | None = None,
Expand All @@ -134,7 +134,11 @@ async def start(
async def stop(self) -> None: ...
```

`Callback[T]` means `Callable[[T], None | Awaitable[None]]`: a synchronous or asynchronous function taking one value of type T. All start arguments are keyword-only. The client exposes two operations, start and stop.
```python
async def query_devices(self) -> tuple[protocol.DeviceEntry, ...]: ...
```

`Callback[T]` means `Callable[[T], None | Awaitable[None]]`: a synchronous or asynchronous function taking one value of type T. All start arguments are keyword-only. The client exposes three operations: start, stop and the read-only device query. `ManusPluginConfig` and `DeviceQueryError` are imported from `capture_server.client`; `DeviceEntry` and `ManusCalibrationCatalogStatus` are in `capture_server.protocol`.

#### Constructor and start arguments

Expand All @@ -143,7 +147,7 @@ async def stop(self) -> None: ...
| Constructor `url` | Nonempty `str`, a `ws://` or `wss://` station URL. | Identifies the remote capture server. Construction creates an idle client without connecting. |
| `sources` | Sequence of unique `SourceType`: `HEAD=1`, `CONTROLLERS=2`, `HANDS=3`. Required; an empty sequence is allowed. | Chooses tracking outputs. CONTROLLERS selects both controller sides; HANDS selects both hand sides. An empty sequence supports camera-only capture. |
| `cameras` | Sequence of `CameraSelection`, default empty; at most three, with unique names. | Selects station-configured cameras by logical name and type. |
| `plugins` | Sequence of unique `PluginType`: `MANUS=1`; default empty. | Enables these tracking plugins. Manus uses its HUMAN dataset and is optional: missing plugin discovery does not itself require session failure. This selection does not require connected gloves or filter hands by vendor. |
| `plugins` | Sequence of unique plugins: a `PluginType` (`MANUS=1`) or a `ManusPluginConfig` that also names MCAL files; default empty. | Enables these tracking plugins. Manus uses its HUMAN dataset and is optional: missing plugin discovery does not itself require session failure. This selection does not require connected gloves or filter hands by vendor. |
| Five `on_...` arguments | `Callback[T] \| None`, default None; argument types appear below. | Registers notifications. None discards that notification; it does not disable acquisition. |

An empty `sources` sequence together with any enabled plugin is rejected as `INVALID_CONFIG`: an enabled plugin needs IC actually entered for discovery to run, but the real IC graph-construction API rejects an empty output mapping outright, so that combination has no valid native graph to build. Plugin requests are otherwise independent of output subscriptions — a session can enable Manus without selecting `HANDS`, and vice versa; only the specific zero-sources-with-an-enabled-plugin combination is rejected, not any particular source choice. Camera-only capture (empty `sources`, no enabled plugin) remains supported and never enters IC.
Expand Down Expand Up @@ -352,6 +356,7 @@ The client validates the wire SessionInit notification and converts it to Captur
| `enabled` | `bool` | Whether the station launches it; true for a plugin selected through start. |
| `required` | `bool` | Whether failure to discover the plugin fails startup; false for start selections. It does not require connected gloves. |
| `datasets` | `tuple[ManusDataset, ...]`: `HUMAN=1`. | Requested live human-skeleton dataset. start selects HUMAN. |
| `left_calibration`, `right_calibration` | `str` or omitted: an `.mcal` basename. | MCAL file for that glove, chosen from the server's MCAL folder (see Device query). Omitted when none was selected. |

#### CaptureClock

Expand Down Expand Up @@ -686,19 +691,29 @@ One JSON object is one text message. One complete header and payload is one bina
| Server → client | 5 | SessionStatus |
| Server → client | 6 | SessionStopped |
| Server → client | 10 | RequestRejected |
| Client → server, on `/query` | 11 | QueryDevices |
| Server → client, on `/query` | 12 | Devices |
| Server → client | BinaryKind 1 | CaptureFrame |
| Server → client | BinaryKind 2 | CaptureImage |

There are ten message families. SessionInit describes the resolved session configuration and complete decoding layout before hardware initialization. SessionStarting reports progress and observed availability. SessionStatus carries IC's changing provider/device state without treating absence as terminal or confusing status with pose validity. No application recording command or file path is accepted.
There are ten session message families, plus the device query described below. SessionInit describes the resolved session configuration and complete decoding layout before hardware initialization. SessionStarting reports progress and observed availability. SessionStatus carries IC's changing provider/device state without treating absence as terminal or confusing status with pose validity. No application recording command or file path is accepted.

Every JSON message has these envelope fields. An envelope is the small common part of a message that allows the receiver to route it before reading message-specific content.
Every JSON session message has these envelope fields. The two `/query` messages instead carry `type`, `version` and a client-chosen `request_id`, and no `session_id`; a query message that carries one is rejected. An envelope is the small common part of a message that allows the receiver to route it before reading message-specific content.

| Field | Allowed contents | What it does |
|---|---|---|
| `type` | `MessageType`: `BEGIN=1`, `STOP=2`, `SESSION_STARTING=3`, `SESSION_BEGUN=4`, `SESSION_STATUS=5`, `SESSION_STOPPED=6`, `SESSION_INIT=9`, `REQUEST_REJECTED=10`. Binary families use the header's BinaryKind instead. | Selects the parser and action. Each message accepts only its own type value. The human-readable names in this document are explanations, not wire values. |
| `type` | `MessageType`: `BEGIN=1`, `STOP=2`, `SESSION_STARTING=3`, `SESSION_BEGUN=4`, `SESSION_STATUS=5`, `SESSION_STOPPED=6`, `SESSION_INIT=9`, `REQUEST_REJECTED=10`, `QUERY_DEVICES=11`, `DEVICES=12`. Binary families use the header's BinaryKind instead. | Selects the parser and action. Each message accepts only its own type value. The human-readable names in this document are explanations, not wire values. |
| `version` | Not an enum: exactly integer 1; not boolean true. | Identifies the protocol contract used to interpret all remaining fields. A receiver must reject another version rather than guess its meaning. |
| `session_id` | Nonempty string, at most 128 characters; a new UUID string is recommended for each new session. | Associates configuration, status, samples and terminal replies with one session. A reply for another ID must not reset the active session. This is a client-chosen correlation ID, not a hardware identifier. |

#### Device query

`QueryDevices` is a read-only request on the server's `/query` path: its own short-lived connection, one request and one reply, no ownership and no session, so it works before capture and while a capture is running. Any other message on `/query`, including every capture command, closes the connection with 1002. `await client.query_devices()` returns the `DeviceEntry` tuple and raises `DeviceQueryError` when no answer is obtained; a failure is never reported as an empty list.

Request: `{"type": 11, "version": 1, "request_id": "<1..128 chars>"}`. Reply: `{"type": 12, "version": 1, "request_id": ..., "devices": [...]}` with one entry per device kind. The Manus entry is `{"device": 1, "mcal_status": ..., "mcal_files": [...], "error_message": ...}`. `mcal_status` is `READY=1` (the list may be empty), `DISABLED=2` (the server has no MCAL folder) or `UNAVAILABLE=3` (the folder could not be read; `error_message` says why). A listing over the 2000-file limit is `UNAVAILABLE`, never truncated.

The MCAL folder is set with `--manus-calibration-dir DIR`. It is a plain folder: any nonempty, readable `.mcal` regular file directly in it is offered, whether placed by hand or written by a calibration tool. Symbolic links are never offered; copy files in. A name is offered only if it uses ASCII letters, digits, `_`, `.` and `-`, starts with a letter or digit, ends in lower-case `.mcal` and is at most 133 characters; any other file (for example `left glove (2).mcal` or `A.MCAL`) is silently skipped, so rename it. Select files by name with `ManusPluginConfig(left_calibration=..., right_calibration=...)` in `start(plugins=...)`. Begin is refused as `INVALID_CONFIG` when a name is not in the folder at that moment. A name only selects a file; whether the glove accepted it is the Manus plugin's per-hand status. If the file disappears or becomes unreadable after Begin is accepted, the plugin cannot load it and both hands are lost for that session.

#### Begin: configure sources, plugins and cameras

```json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ class TrackingSelection:
# silently. Meaningless when manus_enabled is False (no plugin is
# constructed at all in that case).
manus_required: bool = False
# Resolved host paths of MCAL files for the Manus plugin (the server turns the client's basenames into these
# before any hardware starts). Passed to the plugin as its own calibration arguments, so whether the glove
# accepted a file is still reported by the plugin's per-hand status, not assumed here.
manus_left_calibration: str | None = None
manus_right_calibration: str | None = None


class IsaacCaptureAdapter:
Expand Down Expand Up @@ -229,6 +234,7 @@ def _build_config(self) -> Any:
plugin_name=MANUS_PLUGIN_NAME,
plugin_root_id="manus",
search_paths=[],
plugin_args=self._manus_args(),
required=self._selection.manus_required,
)
]
Expand All @@ -237,6 +243,15 @@ def _build_config(self) -> Any:
)
return self._sdk.TeleopSessionConfig(app_name="capture_server", pipeline=pipeline, plugins=plugins)

def _manus_args(self) -> list[str]:
"""The Manus plugin's calibration arguments for the files this session selected."""
args = []
if self._selection.manus_left_calibration is not None:
args.append(f"--left-calibration={self._selection.manus_left_calibration}")
if self._selection.manus_right_calibration is not None:
args.append(f"--right-calibration={self._selection.manus_right_calibration}")
return args

def _enter_sync(self) -> Any:
# CloudXRLauncher.launch_context is the shared launcher/context
# boundary the approved API subset requires the adapter to
Expand Down
87 changes: 83 additions & 4 deletions examples/egocap/capture_server/capture_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from enum import IntEnum
from typing import Any, TypeVar
from typing import Any, ClassVar, TypeVar
from urllib.parse import urlsplit

import numpy as np
import websockets
Expand All @@ -68,6 +69,8 @@

__all__ = [
"CaptureClient",
"DeviceQueryError",
"ManusPluginConfig",
"CaptureFrame",
"CaptureStarted",
"CaptureStatus",
Expand All @@ -86,6 +89,8 @@
_DEFAULT_RATE_HZ = 60.0
_ACCEPT_TIMEOUT_S = 10.0
_STOP_TIMEOUT_S = 10.0
# A device query is one short exchange; the server bounds its own scan well inside this.
_QUERY_TIMEOUT_S = 20.0
_CLOSE_TIMEOUT_S = 2.0
# The documented transport ceiling is a complete JPEG (2 MiB) plus the
# 32-byte binary header; websockets' own default max_size (~1 MiB) would
Expand Down Expand Up @@ -290,6 +295,35 @@ class CaptureStopped:
streams: tuple[p.StreamCounters, ...] | None


@dataclass(frozen=True)
class ManusPluginConfig:
"""The Manus plugin and, optionally, MCAL files to load for each glove.

The names are bare ``.mcal`` basenames of files in the server's managed MCAL folder, as listed by
:meth:`CaptureClient.query_devices`; they are never paths on this machine. A name only selects a file:
whether the glove accepted it is reported by the plugin's own per-hand status.
"""

left_calibration: str | None = None
right_calibration: str | None = None
plugin: ClassVar[p.PluginType] = p.PluginType.MANUS

def __post_init__(self) -> None:
"""Reject names the protocol cannot carry, and one file chosen for both hands."""
for name in (self.left_calibration, self.right_calibration):
if name is not None and not p.is_mcal_name(name):
raise ValueError(f"calibration file must be an .mcal basename, got {name!r}")
if self.left_calibration is not None and self.left_calibration == self.right_calibration:
raise ValueError("left_calibration and right_calibration must name different files")


class DeviceQueryError(Exception):
"""The device list could not be retrieved (transport, timeout, protocol, or a server without the query).

Never reported as an empty list: an answer with no MCAL files is a READY entry with none.
"""


def _is_awaitable(value: Any) -> bool:
return inspect.isawaitable(value)

Expand Down Expand Up @@ -370,7 +404,7 @@ async def start(
*,
sources: Sequence[p.SourceType],
cameras: Sequence[p.CameraSelection] = (),
plugins: Sequence[p.PluginType] = (),
plugins: Sequence[p.PluginType | ManusPluginConfig] = (),
on_started: Callback[CaptureStarted] | None = None,
on_status: Callback[CaptureStatus] | None = None,
on_capture_frame: Callback[CaptureFrame] | None = None,
Expand Down Expand Up @@ -733,18 +767,63 @@ async def _finalize(self, attempt: _Attempt, stopped: CaptureStopped) -> None:
finally:
self._attempt = None

async def query_devices(self) -> tuple[p.DeviceEntry, ...]:
"""List the devices the server offers, without taking the control connection.

Uses its own short-lived connection to the server's read-only ``/query`` route, so it works before
capture and while capturing (including from a callback), and needs no glove or session. The Manus
entry lists the MCAL files a :class:`ManusPluginConfig` can select. Concurrent calls are independent.
Raises :class:`DeviceQueryError` when no answer is obtained, including when the server is too old to
know the query.
"""
# The query route is at the root path of the same listener; a station URL with a path prefix is not
# supported.
url = urlsplit(self._url)._replace(path="/query", query="", fragment="").geturl()
request = p.QueryDevicesRequest(request_id=f"devices-{uuid.uuid4()}")
try:
async with asyncio.timeout(_QUERY_TIMEOUT_S):
async with websockets.connect(url, compression=None, max_size=_MAX_MESSAGE_BYTES) as ws:
await ws.send(_to_json_text(request.to_json()))
raw = await ws.recv()
except ConnectionClosed as exc:
code = exc.rcvd.code if exc.rcvd is not None else None
raise DeviceQueryError(
f"the server closed the device query (close code {code}); it may not support device queries"
) from None
except (OSError, TimeoutError, websockets.exceptions.WebSocketException) as exc:
raise DeviceQueryError(f"the device query failed: {str(exc) or type(exc).__name__}") from None
if isinstance(raw, bytes):
raise DeviceQueryError("the server answered the device query with a binary message")
try:
reply = p.DevicesReply.from_json(_from_json_text(raw))
except p.ProtocolError as exc:
raise DeviceQueryError(f"the server's device reply was invalid: {exc}") from None
# A reply for another request is never accepted as this query's answer.
if reply.request_id != request.request_id:
raise DeviceQueryError("the server's reply does not answer this device query")
return reply.devices

# -- message/result construction ---------------------------------------

def _build_begin_request(
self,
*,
sources: Sequence[p.SourceType],
cameras: Sequence[p.CameraSelection],
plugins: Sequence[p.PluginType],
plugins: Sequence[p.PluginType | ManusPluginConfig],
) -> p.BeginRequest:
try:
tracking = p.TrackingConfig(sources=tuple(sources), rate_hz=_DEFAULT_RATE_HZ)
plugin_requests = tuple(p.PluginRequest(plugin=plugin) for plugin in plugins)
plugin_requests = tuple(
p.PluginRequest(
plugin=plugin.plugin,
left_calibration=plugin.left_calibration,
right_calibration=plugin.right_calibration,
)
if isinstance(plugin, ManusPluginConfig)
else p.PluginRequest(plugin=plugin)
for plugin in plugins
)
camera_requests = tuple(p.CameraRequest(name=c["name"], type=c["type"]) for c in cameras)
return p.BeginRequest(
tracking=tracking,
Expand Down
Loading
Loading