diff --git a/examples/manus_calibration/README.md b/examples/manus_calibration/README.md new file mode 100644 index 0000000000..b98a6319e1 --- /dev/null +++ b/examples/manus_calibration/README.md @@ -0,0 +1,113 @@ + + +# Manus glove calibration + +Calibrate Manus gloves from a browser and save the MCAL files on the machine the gloves are +plugged into. The page guides the operator through the steps the Manus SDK provides; the SDK work +happens in the `manus_glove_calibrator` executable (see +[`src/plugins/manus_calibration/README.md`](../../src/plugins/manus_calibration/README.md)). +No capture session, OpenXR runtime, or CaptureServer is involved. + +This is vendor glove calibration only. Camera calibration, spatial alignment, and flex-sensor +offsets are separate operations. + +## Prerequisites + +- The Manus SDK, with the Manus plugin built against it (`MANUS_SDK_ROOT`, see + `src/plugins/manus/install_manus.sh`), producing `manus_glove_calibrator`. +- A Manus license for the SDK's `Integrated` feature, as Manus requires. Glove discovery and the step + list worked without one in testing, but what the SDK does for a step start, finish and export without a + license has not been established (see Status). +- Gloves and dongle attached to this host, with `src/plugins/manus/70-manus-hid.rules` installed. +- **Every other Manus consumer stopped**: the tracking plugin, capture sessions, other SDK + clients. The dongles are not shared. + +## Run + +The page needs only Python 3.11 or newer and the standard library. The worker supervisor and file storage +come from the `isaaccapture-manus-calibration` library in `src/plugins/manus_calibration/python`; install it +(`pip install ../../src/plugins/manus_calibration/python`) or put it on `PYTHONPATH` as below: + +```bash +cd examples/manus_calibration +PYTHONPATH=python:../../src/plugins/manus_calibration/python python3 -m isaaccapture_examples.manus_calibration \ + --worker /path/to/manus_glove_calibrator \ + --out-dir ~/manus-calibration +``` + +Open `http://127.0.0.1:8080`. The page has no authentication and can drive hardware, so it binds to +loopback and refuses requests whose Host or Origin is not its own; to use it from another machine, +forward the port (`ssh -L 8080:127.0.0.1:8080 host`). + +| Option | Meaning | +| --- | --- | +| `--worker PATH` | The calibration worker executable. | +| `--out-dir DIR` | Where MCAL files are saved, on this host. A launch setting, never browser input. | +| `--sides {left,right,both}` | Gloves preselected in the page. | +| `--host`, `--port` | Where to serve. | +| `--discovery-timeout SECONDS` | How long the worker waits for the requested gloves. | +| `--continuous-pacing operator\|window` | How a continuous step is paced (default `operator`). `operator`: move, then confirm. `window`: confirm, then move for the SDK's estimate while the worker holds the step. Experimental until compared on real gloves. | + +## What the operator sees + +Select the gloves and start. For each SDK step the page shows the SDK's title and instruction. +Every prompt needs its own confirmation, including the first prompt of the second glove, and the +confirmation starts that step in the SDK. + +- A step with a positive SDK time starts a timed sampling window when confirmed, with a progress + bar showing elapsed time (not a quality score). +- A step with no time is confirmed once the operator is already in the pose. +- A step with a negative SDK time is a continuous step whose duration the SDK only estimates. The worker + has no completion signal for it, so a negative time does not prove the SDK finished sampling. With + `--continuous-pacing operator` the page asks you to get into the pose, then confirm, and the next + prompt follows at once. With `window` it asks you to confirm and move immediately; the worker holds + the step for the estimate and the bar shows elapsed time against it, not completion. + +One browser owns an attempt, and it must have the page's event stream open to start one. Other +browsers see its progress but cannot confirm or cancel. If the owning tab disconnects, the attempt is +cancelled after a 3 second grace period, so a page reload does not cancel it; a reload in time keeps +ownership. + +## Unresponsive worker + +The supervisor bounds the answers only the worker can give: startup through the first prompt, the +acknowledgement of a confirmed step, and the time from the last step to the result. When the worker +stays silent past a bound the attempt ends with an uncertain outcome, nothing is saved, and the +worker is terminated. This contains an unresponsive SDK call; it does not show that a license is +missing. A silent worker restarts the bound on any event it sends, so these are inactivity bounds, not +absolute limits. The time the operator takes to confirm a step is never limited. If a worker cannot be +stopped at all, new attempts are refused until the program restarts, because it may still hold the gloves. + +## Status + +Exercised in tests: the worker, the supervisor, storage and the page behave as described against a scripted +stand-in for the worker, and the real SDK discovers both gloves and returns the step lists. + +Not verified: a calibration run from the first step to saved files with real gloves. In testing, the SDK +call that starts a step did not return within 30 seconds, and the cause was not established. So an +exported file, its calibration quality, and loading it through the tracking plugin remain unverified. + +## Saved files + +`--out-dir` is a plain folder. The worker writes each file straight into it as `_.mcal` +(a half-written file carries a `.part` suffix and is never left behind), and a name that exists is never +overwritten. A cancelled, failed, or uncertain attempt removes whatever it wrote, so only a completed +calibration leaves files. When a calibration completes the page lists the files with a download link each, +so the operator can save them to the machine the browser runs on. Loading a file for tracking is done with +`manus_hand_plugin --left-calibration=FILE --right-calibration=FILE`. + +## Tests + +```bash +cd tests/python/examples/manus_calibration +uv run --extra dev pytest +``` + +CTest runs the same suite as `manus_calibration_*` (label `manus_calibration`). + +The tests drive the adapter, controller and HTTP server against a scripted stand-in for the worker, so +they establish the supervision, storage, ownership and web-safety logic, not that the SDK produces a +valid MCAL file. diff --git a/examples/manus_calibration/pyproject.toml b/examples/manus_calibration/pyproject.toml new file mode 100644 index 0000000000..9d30795c46 --- /dev/null +++ b/examples/manus_calibration/pyproject.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +# The dist name mirrors the import path, so an installed example claims no bare +# top-level name in site-packages. +name = "isaaccapture-examples-manus-calibration" +version = "0.0.0" # Internal example - not versioned +description = "Standalone Manus glove calibration with a browser page" +requires-python = ">=3.11,<3.14" +# The worker supervisor and MCAL storage live in a standard-library-only library beside the native worker; +# calibration needs no OpenXR, CUDA, capture session or UI toolkit. +dependencies = ["isaaccapture-manus-calibration"] + +[project.optional-dependencies] +dev = [ + "pytest>=8", +] + +# `only-include` + `sources`, not `packages`, and no __init__.py above +# manus_calibration -- see the packaging rules in examples/README.md. +[tool.hatch.build] +only-include = ["python/isaaccapture_examples/manus_calibration"] + +[tool.hatch.build.targets.wheel] +sources = ["python"] + +[tool.uv.sources] +isaaccapture-manus-calibration = { path = "../../src/plugins/manus_calibration/python" } diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__init__.py b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__init__.py new file mode 100644 index 0000000000..3507e90c7f --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Standalone Manus glove calibration: worker supervision, MCAL storage and a browser page.""" diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__main__.py b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__main__.py new file mode 100644 index 0000000000..bc16a9b015 --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/__main__.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Command line for the standalone Manus calibration page.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import signal +import sys +from pathlib import Path + +from .server import PageServer +from isaaccapture_manus_calibration.worker import CalibrationWorker + +logger = logging.getLogger("isaaccapture.examples.manus_calibration") + + +def build_parser() -> argparse.ArgumentParser: + """Arguments for the page; the output directory is a launch setting, never browser input.""" + parser = argparse.ArgumentParser( + prog="python -m isaaccapture_examples.manus_calibration", + description="Calibrate Manus gloves from a browser and save the MCAL files on this host.", + ) + parser.add_argument( + "--worker", + required=True, + type=Path, + help="path to the manus_glove_calibrator executable", + ) + parser.add_argument( + "--out-dir", required=True, type=Path, help="calibration directory on this host" + ) + parser.add_argument( + "--sides", + choices=("left", "right", "both"), + default="both", + help="gloves preselected in the page (default: both)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="interface to serve on; the page has no authentication, so keep loopback and use SSH port forwarding", + ) + parser.add_argument( + "--port", type=int, default=8080, help="port to serve on (default: 8080)" + ) + parser.add_argument( + "--discovery-timeout", + type=float, + default=15.0, + help="seconds the worker waits for the requested gloves (default: 15)", + ) + parser.add_argument( + "--continuous-pacing", + choices=("operator", "window"), + default="operator", + help="how a continuous step is paced: 'operator' (move, then confirm; the step ends at once) or " + "'window' (confirm, then move for the SDK's estimate); default: operator", + ) + return parser + + +def _validate(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Fail early, with a usage error, on a worker path that cannot run.""" + if not args.worker.is_file() or not os.access(args.worker, os.X_OK): + parser.error(f"--worker {args.worker} is not an executable file") + if not 0 <= args.port < 65536: + parser.error("--port must be between 0 and 65535") + if args.discovery_timeout <= 0: + parser.error("--discovery-timeout must be positive") + + +async def _serve(args: argparse.Namespace) -> int: + """Serve until interrupted, then cancel any active attempt and wait for the worker to settle.""" + out_dir = args.out_dir.resolve() + worker = CalibrationWorker( + args.worker, + out_dir, + discovery_timeout_s=args.discovery_timeout, + continuous_pacing=args.continuous_pacing, + ) + server = PageServer(worker, out_dir, args.host, args.port, args.sides) + await server.start() + logger.info( + "serving on http://%s:%d, calibration files go to %s", + args.host, + server.port, + out_dir, + ) + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + # SIGHUP: a dropped terminal session must cancel an active calibration rather than kill the page mid-write. + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + loop.add_signal_handler(sig, stop.set) + await stop.wait() + logger.info("shutting down; cancelling any active calibration") + await server.aclose() + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Entry point.""" + parser = build_parser() + args = parser.parse_args(argv) + _validate(args, parser) + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + return asyncio.run(_serve(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/controller.py b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/controller.py new file mode 100644 index 0000000000..eed5aa0082 --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/controller.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Presentation state and ownership rules for one calibration at a time, with no UI toolkit. + +Every method runs on the asyncio loop that owns the worker. A UI renders +:meth:`CalibrationController.view`; nothing here (or in the UI) calls the SDK. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from enum import Enum + +from isaaccapture_manus_calibration import protocol as proto +from isaaccapture_manus_calibration.worker import ( + AttemptError, + CalibrationAttempt, + CalibrationOutcome, + CalibrationWorker, + OutcomeKind, +) + + +class Stage(str, Enum): + """Coarse stage the page renders.""" + + IDLE = "idle" + STARTING = "starting" + DISCOVERING = "discovering" + AWAITING_CONFIRMATION = "awaiting_confirmation" + SAMPLING = "sampling" + SAVING = "saving" + STOPPING = "stopping" + FINISHED = "finished" + + +@dataclass(frozen=True) +class View: + """Immutable snapshot of everything the page shows.""" + + stage: Stage = Stage.IDLE + owner: str | None = None + sides: tuple[proto.Side, ...] = () + gloves: tuple[proto.Glove, ...] = () + prompt: proto.Prompt | None = None + confirm_pending: bool = False + elapsed_s: float | None = None + notice: str = "" + outcome: CalibrationOutcome | None = None + + +class NotOwner(Exception): + """The caller is not the client that owns the active attempt.""" + + +class CalibrationController: + """Owns the single attempt, its owner, and the view derived from worker events.""" + + def __init__( + self, + worker: CalibrationWorker, + on_change: Callable[[View], None] | None = None, + ) -> None: + self._worker = worker + self._on_change = on_change + self._view = View() + self._attempt: CalibrationAttempt | None = None + self._watcher: asyncio.Task[None] | None = None + + def view(self) -> View: + return self._view + + @property + def active(self) -> bool: + """True from the start request until the terminal outcome is recorded.""" + return self._attempt is not None + + async def start(self, client_id: str, sides: Sequence[proto.Side]) -> None: + """Begin an attempt owned by ``client_id``; refused while another is active.""" + if self._attempt is not None: + raise AttemptError("a calibration attempt is already active") + try: + attempt = self._worker.new_attempt(self._on_event) + except AttemptError: + # The worker is still winding down a previous attempt. + self._notice("the previous attempt is still shutting down") + raise + self._attempt = attempt + self._set( + View( + stage=Stage.STARTING, + owner=client_id, + sides=tuple(s for s in proto.Side if s in sides), + ) + ) + self._watcher = asyncio.create_task(self._watch(attempt)) + try: + await attempt.start(sides) + except Exception: + # start() settles worker-side failures itself; a rejected request never reached the worker, so + # settle it here and wait for the watcher, leaving the controller free for the next start. + await attempt.cancel() + await self._watcher + raise + + async def confirm(self, client_id: str, token: str) -> None: + """Start the SDK step the browser is looking at; a click on a superseded prompt is refused.""" + attempt = self._require_owner(client_id) + prompt = self._view.prompt + if prompt is None or prompt.token != token: + raise AttemptError("that prompt is no longer current") + if self._view.confirm_pending: + return + self._set(replace(self._view, confirm_pending=True, notice="")) + try: + await attempt.confirm(prompt.token) + except AttemptError as exc: + self._notice(str(exc)) + finally: + if self._attempt is attempt and self._view.confirm_pending: + self._set(replace(self._view, confirm_pending=False)) + + async def cancel(self, client_id: str) -> None: + """Cancel the active attempt; only its owner may.""" + attempt = self._require_owner(client_id) + self._set(replace(self._view, stage=Stage.STOPPING, notice="cancelling")) + await attempt.cancel() + + def owner_attempt(self, client_id: str) -> CalibrationAttempt | None: + """The active attempt ``client_id`` owns, if any; lets a caller bind a later cancel to it.""" + if self._attempt is not None and self._view.owner == client_id: + return self._attempt + return None + + async def client_disconnected( + self, client_id: str, attempt: CalibrationAttempt | None = None + ) -> None: + """Owner loss cancels an active attempt; completed files are unaffected. + + With ``attempt`` given, only that attempt is cancelled, so a late timer cannot hit a newer one. + """ + current = self._attempt + if current is None or self._view.owner != client_id: + return + if attempt is not None and attempt is not current: + return + await current.cancel() + + async def aclose(self) -> None: + """Cancel any active attempt and wait for its outcome, for orderly shutdown.""" + attempt = self._attempt + if attempt is None: + return + await attempt.cancel() + if self._watcher is not None: + with contextlib.suppress(asyncio.CancelledError): + await self._watcher + + def _require_owner(self, client_id: str) -> CalibrationAttempt: + attempt = self._attempt + if attempt is None: + raise AttemptError("no calibration attempt is active") + if self._view.owner != client_id: + raise NotOwner( + "only the browser that started this calibration can control it" + ) + return attempt + + def _on_event(self, event: proto.WorkerEvent) -> None: + """Fold one worker event into the view; the outcome arrives through :meth:`_watch`.""" + view = self._view + if isinstance(event, proto.Status): + self._set( + replace(view, gloves=event.gloves, stage=_STAGE_OF_PHASE[event.phase]) + ) + elif isinstance(event, proto.Prompt): + self._set( + replace( + view, + stage=Stage.AWAITING_CONFIRMATION, + prompt=event, + confirm_pending=False, + elapsed_s=None, + notice="", + ) + ) + elif isinstance(event, proto.StepAccepted): + self._set(replace(view, stage=Stage.SAMPLING)) + elif isinstance(event, proto.StepProgress): + self._set(replace(view, elapsed_s=event.elapsed_s)) + + async def _watch(self, attempt: CalibrationAttempt) -> None: + """Record the terminal outcome and release ownership so a new attempt can start.""" + outcome = await attempt.outcome() + self._attempt = None + self._set( + View( + stage=Stage.FINISHED, + sides=outcome.sides, + outcome=outcome, + notice=_summary(outcome), + ) + ) + + def _notice(self, text: str) -> None: + self._set(replace(self._view, notice=text)) + + def _set(self, view: View) -> None: + self._view = view + if self._on_change is not None: + self._on_change(view) + + +_STAGE_OF_PHASE = { + proto.Phase.DISCOVERING: Stage.DISCOVERING, + proto.Phase.AWAITING_STEP: Stage.AWAITING_CONFIRMATION, + proto.Phase.CAPTURING_STEP: Stage.SAMPLING, + proto.Phase.SAVING: Stage.SAVING, + proto.Phase.STOPPING: Stage.STOPPING, +} + + +def _summary(outcome: CalibrationOutcome) -> str: + """One line for the page; an uncertain outcome is never described as success.""" + if outcome.kind is OutcomeKind.COMPLETED: + return "calibration completed" + if outcome.kind is OutcomeKind.CANCELLED: + return "calibration cancelled; no files were saved" + if outcome.kind is OutcomeKind.UNCERTAIN: + return f"outcome uncertain, no files were saved: {outcome.message}" + return f"calibration failed: {outcome.message}" diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/server.py b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/server.py new file mode 100644 index 0000000000..cd2b0a1f65 --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/server.py @@ -0,0 +1,453 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A small HTTP server for the calibration page, using only the standard library. + +Static files and a server-sent-event stream per browser tab carry the controller's view out; three +POST endpoints carry the operator's actions in. The page can drive hardware and has no login, so +every request is checked for the page's own origin and every action must carry a per-tab client id. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from isaaccapture_manus_calibration import protocol as proto +from .controller import CalibrationController, NotOwner, View +from .wire import view_to_dict +from isaaccapture_manus_calibration.worker import ( + AttemptError, + CalibrationWorker, + OutcomeKind, +) + +logger = logging.getLogger("isaaccapture.examples.manus_calibration.server") + +_STATIC_DIR = Path(__file__).resolve().parent / "static" +_STATIC_TYPES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} +_DOWNLOAD_PREFIX = "/download/" +_CLIENT_ID_RE = re.compile(r"[A-Za-z0-9_-]{8,64}") +_MAX_HEADER_BYTES = 16 * 1024 +_MAX_BODY_BYTES = 4 * 1024 +_REQUEST_TIMEOUT_S = 10.0 +_KEEPALIVE_S = 15.0 +# A reload closes the old stream before the new one opens; this window keeps it from cancelling. +_DISCONNECT_GRACE_S = 3.0 +_LOOPBACK_NAMES = ("127.0.0.1", "localhost", "[::1]") + +_SECURITY_HEADERS = { + "Content-Security-Policy": ( + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ), + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", +} +_REASONS = { + 200: "OK", + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 413: "Payload Too Large", + 500: "Internal Server Error", +} + + +class _BadRequest(Exception): + """The request cannot be parsed or breaks a limit; carries the HTTP status to answer with.""" + + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + + +@dataclass +class _Request: + method: str + path: str + query: dict[str, list[str]] + headers: dict[str, str] + body: bytes + + +@dataclass +class _Tab: + """One browser tab's server-sent-event state; its id is chosen by the page.""" + + connections: int = 0 + changed: asyncio.Event = field(default_factory=asyncio.Event) + grace: asyncio.TimerHandle | None = None + + +class PageServer: + """Serves the page and runs the controller on the caller's event loop.""" + + def __init__( + self, + worker: CalibrationWorker, + out_dir: Path, + host: str, + port: int, + default_sides: str = "both", + ) -> None: + self._continuous_pacing = worker.continuous_pacing + self._out_dir = str(out_dir) + self._default_sides = default_sides + self._host = host + self._requested_port = port + self._controller = CalibrationController(worker, self._on_change) + self._tabs: dict[str, _Tab] = {} + # Strong references: the loop keeps only weak ones, and a dropped task would skip the cancel. + self._background: set[asyncio.Task[None]] = set() + self._server: asyncio.Server | None = None + self._allowed_hosts: set[str] = set() + self._static = { + path: (name, ctype, (_STATIC_DIR / name).read_bytes()) + for path, (name, ctype) in _STATIC_TYPES.items() + } + + @property + def controller(self) -> CalibrationController: + return self._controller + + @property + def port(self) -> int: + """The bound port; differs from the requested one when that was 0.""" + assert self._server is not None + return self._server.sockets[0].getsockname()[1] + + async def start(self) -> None: + """Begin listening.""" + self._server = await asyncio.start_server( + self._handle, self._host, self._requested_port + ) + names = ( + set(_LOOPBACK_NAMES) + if self._host in ("127.0.0.1", "localhost", "::1") + else {self._host} + ) + self._allowed_hosts = {f"{name}:{self.port}" for name in names} + + async def aclose(self) -> None: + """Cancel any active attempt, wait for the worker to settle, then stop listening.""" + await self._controller.aclose() + if self._server is not None: + self._server.close() + for tab in self._tabs.values(): + tab.changed.set() + await self._server.wait_closed() + + def _on_change(self, _view: View) -> None: + """Wake every open stream; each sends the controller's current view, so bursts coalesce.""" + for tab in self._tabs.values(): + tab.changed.set() + + async def _handle( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """One connection: parse one request, answer it, close. A stream request stays open.""" + try: + try: + request = await asyncio.wait_for( + self._read_request(reader), _REQUEST_TIMEOUT_S + ) + self._check_origin(request) + await self._route(request, reader, writer) + except _BadRequest as exc: + await self._reply_json( + writer, exc.status, {"ok": False, "error": str(exc)} + ) + except (asyncio.TimeoutError, ConnectionError): + # The client gave up or went away mid-request: nobody is left to answer. + pass + except Exception: + # Detail stays in the log; the browser gets no stack trace. + logger.exception("request failed") + with contextlib.suppress(Exception): + await self._reply_json( + writer, 500, {"ok": False, "error": "internal error"} + ) + finally: + with contextlib.suppress(Exception): + writer.close() + await writer.wait_closed() + + async def _read_request(self, reader: asyncio.StreamReader) -> _Request: + """Parse the request line, headers and a bounded body.""" + try: + block = await reader.readuntil(b"\r\n\r\n") + except asyncio.LimitOverrunError: + raise _BadRequest(413, "request headers too large") from None + except asyncio.IncompleteReadError: + raise ConnectionError from None + if len(block) > _MAX_HEADER_BYTES: + raise _BadRequest(413, "request headers too large") + lines = block.decode("latin-1").split("\r\n") + parts = lines[0].split(" ") + if len(parts) != 3 or not parts[2].startswith("HTTP/1."): + raise _BadRequest(400, "malformed request line") + headers: dict[str, str] = {} + for line in lines[1:]: + if not line: + continue + name, sep, value = line.partition(":") + if not sep: + raise _BadRequest(400, "malformed header") + headers[name.strip().lower()] = value.strip() + length_text = headers.get("content-length", "0") + if not length_text.isdigit() or int(length_text) > _MAX_BODY_BYTES: + raise _BadRequest(413, "request body too large") + try: + body = await reader.readexactly(int(length_text)) + except asyncio.IncompleteReadError: + raise ConnectionError from None + split = urlsplit(parts[1]) + return _Request(parts[0], split.path, parse_qs(split.query), headers, body) + + def _check_origin(self, request: _Request) -> None: + """Refuse a foreign Host (DNS rebinding) or a cross-origin page; a missing Origin is a same-origin GET.""" + host = request.headers.get("host", "") + if host not in self._allowed_hosts: + raise _BadRequest(403, "unexpected Host header") + origin = request.headers.get("origin") + if origin is not None and origin != f"http://{host}": + raise _BadRequest(403, "cross-origin request refused") + + async def _route( + self, + request: _Request, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Dispatch on method and path.""" + if request.method == "GET" and request.path in self._static: + _name, ctype, body = self._static[request.path] + await self._reply(writer, 200, ctype, body) + elif request.method == "GET" and request.path == "/events": + await self._stream(request, reader, writer) + elif request.method == "GET" and request.path.startswith(_DOWNLOAD_PREFIX): + await self._download(request.path[len(_DOWNLOAD_PREFIX) :], writer) + elif request.method == "POST" and request.path in ( + "/api/start", + "/api/confirm", + "/api/cancel", + ): + await self._action(request, writer) + elif request.path in self._static or request.path in ( + "/events", + "/api/start", + "/api/confirm", + "/api/cancel", + ): + raise _BadRequest(405, "method not allowed") + else: + raise _BadRequest(404, "not found") + + async def _download(self, name: str, writer: asyncio.StreamWriter) -> None: + """Send one file of the last completed calibration to the browser as a download. + + Only names the last completed attempt reported are served, so the route can never read anything else + in the folder. + """ + outcome = self._controller.view().outcome + offered = ( + {f.file for f in outcome.files} + if outcome is not None and outcome.kind is OutcomeKind.COMPLETED + else set() + ) + if name not in offered: + raise _BadRequest(404, "not found") + try: + data = await asyncio.to_thread((Path(self._out_dir) / name).read_bytes) + except OSError: + raise _BadRequest(404, "the file is no longer available") from None + await self._reply( + writer, + 200, + "application/octet-stream", + data, + {"Content-Disposition": f'attachment; filename="{name}"'}, + ) + + async def _action(self, request: _Request, writer: asyncio.StreamWriter) -> None: + """An operator action; the custom header and JSON type make a cross-site form post impossible.""" + client = request.headers.get("x-client-id", "") + if not _CLIENT_ID_RE.fullmatch(client): + raise _BadRequest(400, "missing or invalid X-Client-Id") + if ( + request.headers.get("content-type", "").split(";")[0].strip() + != "application/json" + ): + raise _BadRequest(400, "content type must be application/json") + try: + data: Any = json.loads(request.body or b"{}") + except ValueError: + raise _BadRequest(400, "body is not valid JSON") from None + if not isinstance(data, dict): + raise _BadRequest(400, "body must be a JSON object") + try: + if request.path == "/api/start": + # Without a live stream no disconnect could ever cancel the attempt this tab would own. + tab = self._tabs.get(client) + if tab is None or tab.connections == 0: + raise _BadRequest(409, "the page's event stream is not connected") + await self._controller.start(client, _sides_of(data)) + elif request.path == "/api/confirm": + token = data.get("token") + if not isinstance(token, str) or not token: + raise _BadRequest(400, "token is required") + await self._controller.confirm(client, token) + else: + await self._controller.cancel(client) + except NotOwner as exc: + raise _BadRequest(403, str(exc)) from None + except AttemptError as exc: + raise _BadRequest(409, str(exc)) from None + except proto.ProtocolError as exc: + raise _BadRequest(400, str(exc)) from None + await self._reply_json(writer, 200, {"ok": True}) + + async def _stream( + self, + request: _Request, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Server-sent events for one tab until it disconnects or the server closes.""" + ids = request.query.get("client", []) + if len(ids) != 1 or not _CLIENT_ID_RE.fullmatch(ids[0]): + raise _BadRequest(400, "missing or invalid client id") + client = ids[0] + tab = self._tabs.setdefault(client, _Tab()) + if tab.grace is not None: + tab.grace.cancel() + tab.grace = None + tab.connections += 1 + head = { + "Content-Type": "text/event-stream", + "Connection": "close", + **_SECURITY_HEADERS, + } + writer.write(_head(200, head)) + # EOF on the request side is the only portable sign that the tab went away. + closed = asyncio.ensure_future(reader.read()) + try: + while self._server is not None and self._server.is_serving(): + # Cleared before the snapshot, so a change that lands while writing wakes the next wait. + tab.changed.clear() + payload = json.dumps( + view_to_dict( + self._controller.view(), + client, + self._out_dir, + self._default_sides, + self._continuous_pacing, + ) + ) + writer.write(f"data: {payload}\n\n".encode()) + await writer.drain() + waiter = asyncio.ensure_future(tab.changed.wait()) + done, _pending = await asyncio.wait( + {waiter, closed}, + timeout=_KEEPALIVE_S, + return_when=asyncio.FIRST_COMPLETED, + ) + waiter.cancel() + if closed in done: + break + if not done: + writer.write(b": keepalive\n\n") + await writer.drain() + except ConnectionError: + # The tab closed mid-write; the cleanup below treats it as a normal disconnect. + pass + finally: + closed.cancel() + tab.connections -= 1 + if tab.connections == 0: + self._schedule_disconnect(client, tab) + + def _schedule_disconnect(self, client: str, tab: _Tab) -> None: + """Treat the tab as gone only if it does not come back within the grace window.""" + loop = asyncio.get_running_loop() + # Bound now to the attempt this tab owns, so a late timer can never cancel a newer attempt. + attempt = self._controller.owner_attempt(client) + + def _gone() -> None: + tab.grace = None + if tab.connections != 0: + return + self._tabs.pop(client, None) + if attempt is None: + return + task = loop.create_task( + self._controller.client_disconnected(client, attempt) + ) + self._background.add(task) + task.add_done_callback(self._background.discard) + + tab.grace = loop.call_later(_DISCONNECT_GRACE_S, _gone) + + async def _reply( + self, + writer: asyncio.StreamWriter, + status: int, + ctype: str, + body: bytes, + extra_headers: dict[str, str] | None = None, + ) -> None: + """A complete response with the security headers.""" + headers = { + "Content-Type": ctype, + "Content-Length": str(len(body)), + "Connection": "close", + **_SECURITY_HEADERS, + **(extra_headers or {}), + } + writer.write(_head(status, headers) + body) + await writer.drain() + + async def _reply_json( + self, writer: asyncio.StreamWriter, status: int, data: dict[str, Any] + ) -> None: + await self._reply(writer, status, "application/json", json.dumps(data).encode()) + + +def _head(status: int, headers: dict[str, str]) -> bytes: + """Status line and headers.""" + lines = [f"HTTP/1.1 {status} {_REASONS.get(status, 'Status')}"] + lines += [f"{name}: {value}" for name, value in headers.items()] + return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + + +def _sides_of(data: dict[str, Any]) -> list[proto.Side]: + """The sides a start request names; anything but a nonempty list of left/right is a bad request.""" + raw = data.get("sides") + if ( + not isinstance(raw, list) + or not raw + or not all(isinstance(item, str) for item in raw) + ): + raise _BadRequest(400, "sides must be a nonempty list") + try: + sides = [proto.Side(item) for item in raw] + except ValueError: + raise _BadRequest(400, "sides must be left or right") from None + if len(set(sides)) != len(sides): + raise _BadRequest(400, "sides must not repeat") + return sides diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/app.js b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/app.js new file mode 100644 index 0000000000..579af79b3a --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/app.js @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +// Every piece of text from the server is assigned with textContent, never as markup. + +const STAGE_TEXT = { + idle: "Ready.", + starting: "Starting the calibration worker...", + discovering: "Looking for the requested gloves...", + awaiting_confirmation: "Waiting for your confirmation.", + sampling: "The SDK is sampling. Follow the instruction below.", + sampling_estimate: + "The SDK started this step. Keep following the instruction; the bar is a time estimate, not a sign the step finished.", + saving: "Saving the calibration files...", + stopping: "Stopping...", + finished: "Finished.", +}; +const RESULT_HEADING = { + completed: "Calibration saved", + cancelled: "Calibration cancelled", + failed: "Calibration failed", + uncertain: "Outcome uncertain", +}; + +const $ = (id) => document.getElementById(id); + +// One id per browser tab: it names the tab as the owner of an attempt, and survives a reload. +const clientId = (() => { + let id = sessionStorage.getItem("manusCalibrationClient"); + if (!id) { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + sessionStorage.setItem("manusCalibrationClient", id); + } + return id; +})(); + +let state = null; +let requestInFlight = false; +let cancelInFlight = false; +let localError = ""; + +async function post(path, body) { + const isCancel = path === "/api/cancel"; + requestInFlight = !isCancel || requestInFlight; + cancelInFlight = isCancel || cancelInFlight; + localError = ""; + render(); + try { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Client-Id": clientId }, + body: JSON.stringify(body), + }); + const result = await response.json(); + if (!result.ok) { + localError = result.error || "the request was refused"; + } + } catch (error) { + localError = "cannot reach the server"; + } finally { + if (isCancel) { + cancelInFlight = false; + } else { + requestInFlight = false; + } + render(); + } +} + +function selectedSides() { + const value = document.querySelector('input[name="sides"]:checked').value; + return value === "both" ? ["left", "right"] : [value]; +} + +// A continuous step the worker holds for the SDK's estimate after the step starts. +function windowContinuous(prompt) { + return Boolean(prompt && prompt.continuous && state.continuous_pacing === "window"); +} + +function guidance(prompt) { + if (prompt.mode === "motion") { + return `Confirm, then perform the movement for ${prompt.duration_s.toFixed(1)} seconds.`; + } + if (windowContinuous(prompt)) { + if (state.confirm_pending || requestInFlight) { + // Do not make the operator wait for the SDK before moving: the call may take a while to return. + return "Follow the instruction now. Waiting for the SDK to start the step."; + } + return `Confirm, then begin the instructed movement immediately and continue for about ${Math.round(prompt.estimated_s)} seconds.`; + } + if (prompt.continuous) { + return `Get into the pose (or start the movement) for about ${Math.round(prompt.estimated_s)} seconds, then confirm.`; + } + return "Hold the pose first, then confirm to capture it."; +} + +function confirmLabel(prompt) { + if (state.confirm_pending || requestInFlight) { + return "Starting..."; + } + if (windowContinuous(prompt)) { + return `Start step (~${Math.round(prompt.estimated_s)} s)`; + } + return prompt.mode === "motion" ? `Start motion (${Math.round(prompt.duration_s)} s)` : "Capture pose"; +} + +function renderStatus() { + if (state.stage === "idle") { + $("stage").textContent = "Ready."; + } else if (state.active && !state.owner) { + $("stage").textContent = + "Another browser is running a calibration; this page is read-only until it finishes."; + } else { + const timedEstimate = state.stage === "sampling" && windowContinuous(state.prompt); + $("stage").textContent = timedEstimate + ? STAGE_TEXT.sampling_estimate + : STAGE_TEXT[state.stage] || state.stage; + } + $("gloves").textContent = state.gloves.length + ? "Gloves found: " + state.gloves.map((g) => `${g.side} (id ${g.glove_id})`).join(", ") + : ""; + $("notice").textContent = localError || (state.stage === "finished" ? "" : state.notice); +} + +function renderStep() { + const prompt = state.prompt; + $("step").hidden = !prompt; + if (!prompt) { + return; + } + const side = prompt.side.charAt(0).toUpperCase() + prompt.side.slice(1); + $("step-heading").textContent = `${side} glove, step ${prompt.index + 1} of ${prompt.count}`; + $("step-title").textContent = prompt.title; + $("step-instruction").textContent = prompt.instruction; + $("step-guidance").textContent = guidance(prompt); + const sampling = state.stage === "sampling" && (prompt.mode === "motion" || windowContinuous(prompt)); + $("progress").hidden = !sampling; + const window_s = prompt.continuous ? prompt.estimated_s : prompt.duration_s; + if (sampling && window_s > 0 && state.elapsed_s !== null) { + $("progress").value = Math.min(100, (100 * state.elapsed_s) / window_s); + } +} + +function renderControls() { + const prompt = state.prompt; + // Other browsers watch; only the owner gets controls. + $("start").disabled = state.active || requestInFlight; + for (const radio of document.querySelectorAll('input[name="sides"]')) { + radio.disabled = state.active; + } + $("actions").hidden = !(state.active && state.owner); + $("confirm").hidden = !prompt; + if (prompt) { + $("confirm").textContent = confirmLabel(prompt); + $("confirm").disabled = !( + state.stage === "awaiting_confirmation" && prompt.token && !state.confirm_pending && !requestInFlight + ); + } + // Cancel stays available while a confirm is in flight: that is when a stuck step needs it. + $("cancel").disabled = state.stage === "stopping" || cancelInFlight; +} + +function renderResult() { + const outcome = state.outcome; + $("result").hidden = !outcome || state.active; + if (!outcome || state.active) { + return; + } + $("result-heading").textContent = RESULT_HEADING[outcome.kind] || outcome.kind; + $("result-message").textContent = outcome.message; + $("result-dir").hidden = !outcome.directory; + $("result-dir").textContent = outcome.directory ? `Saved on the server host in ${outcome.directory}:` : ""; + const list = $("result-files"); + list.replaceChildren( + ...outcome.files.map((file) => { + const item = document.createElement("li"); + // Same-origin download of the file the worker just wrote on the server host. + const link = document.createElement("a"); + link.href = `/download/${encodeURIComponent(file.file)}`; + link.download = file.file; + link.textContent = file.file; + item.append(`${file.side}: `, link, ` (${file.bytes} bytes)`); + return item; + }), + ); + if (!outcome.directory) { + const none = document.createElement("li"); + none.textContent = "No calibration files were saved."; + list.replaceChildren(none); + } + $("result-log").hidden = outcome.log_tail.length === 0; + $("result-log").textContent = outcome.log_tail.join("\n"); +} + +function render() { + if (!state) { + return; + } + $("out-dir").textContent = state.out_dir; + renderStatus(); + renderStep(); + renderControls(); + renderResult(); +} + +$("start").addEventListener("click", () => post("/api/start", { sides: selectedSides() })); +$("confirm").addEventListener("click", () => { + if (state && state.prompt && state.prompt.token) { + post("/api/confirm", { token: state.prompt.token }); + } +}); +$("cancel").addEventListener("click", () => post("/api/cancel", {})); + +const events = new EventSource(`/events?client=${clientId}`); +let defaultApplied = false; +events.onmessage = (event) => { + state = JSON.parse(event.data); + if (!defaultApplied) { + // The launch option preselects the gloves; the operator can still change it. + const radio = document.querySelector(`input[name="sides"][value="${state.default_sides}"]`); + if (radio) { + radio.checked = true; + } + defaultApplied = true; + } + render(); +}; +events.onerror = () => { + $("notice").textContent = "Connection lost; retrying..."; +}; diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/index.html b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/index.html new file mode 100644 index 0000000000..a4c269ae96 --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/index.html @@ -0,0 +1,60 @@ + + + + + + + Manus glove calibration + + + +
+

Manus glove calibration

+

+ Stop the capture session and every other Manus consumer first: the gloves are not shared. + Calibration files are saved on the server host in . +

+ +
+
+ Gloves + + + +
+ +
+ +
+

+

+ +
+ + + + + + +
+ + + diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/style.css b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/style.css new file mode 100644 index 0000000000..e051cf45bf --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/static/style.css @@ -0,0 +1,114 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:root { + --bg: #fff; + --fg: #1b1f24; + --muted: #57606a; + --accent: #1a7f37; + --danger: #cf222e; + --border: #d0d7de; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #9198a1; + --accent: #3fb950; + --danger: #f85149; + --border: #30363d; + } +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 16px/1.5 system-ui, sans-serif; +} + +main { + max-width: 40rem; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.2rem; + margin-bottom: 0.25rem; +} + +section { + margin: 1.25rem 0; +} + +fieldset { + border: 1px solid var(--border); + border-radius: 6px; + display: inline-block; + margin: 0 0 1rem; +} + +label { + margin-right: 1rem; +} + +button { + font: inherit; + padding: 0.6rem 1.2rem; + border: 0; + border-radius: 6px; + background: var(--accent); + color: #fff; + cursor: pointer; +} + +button.danger { + background: var(--danger); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.actions { + display: flex; + gap: 0.75rem; +} + +.step-title { + font-weight: 600; +} + +.hint { + color: var(--muted); + font-style: italic; +} + +progress { + width: 100%; + height: 1rem; +} + +#notice { + font-weight: 600; +} + +pre { + overflow-x: auto; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.8rem; +} + +code { + overflow-wrap: anywhere; +} diff --git a/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/wire.py b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/wire.py new file mode 100644 index 0000000000..da9def0f60 --- /dev/null +++ b/examples/manus_calibration/python/isaaccapture_examples/manus_calibration/wire.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""JSON the page renders, built from the controller's view for one browser.""" + +from __future__ import annotations + +from typing import Any + +from .controller import Stage, View + +_LOG_LINES = 8 +_ACTIVE = frozenset( + { + Stage.STARTING, + Stage.DISCOVERING, + Stage.AWAITING_CONFIRMATION, + Stage.SAMPLING, + Stage.SAVING, + Stage.STOPPING, + } +) + + +def view_to_dict( + view: View, + client_id: str, + out_dir: str, + default_sides: str = "both", + continuous_pacing: str = "operator", +) -> dict[str, Any]: + """Snapshot for ``client_id``; only the owning browser is offered the prompt token.""" + owner = view.owner == client_id + active = view.stage in _ACTIVE + prompt = None + if view.prompt is not None and active: + prompt = { + "side": view.prompt.side.value, + "index": view.prompt.index, + "count": view.prompt.count, + "title": view.prompt.title, + "instruction": view.prompt.instruction, + "mode": view.prompt.mode.value, + "duration_s": view.prompt.duration_s, + "continuous": view.prompt.continuous, + "estimated_s": view.prompt.estimated_s, + # The token lets the owner's click name the prompt it saw; others never need it. + "token": view.prompt.token if owner else None, + } + return { + "stage": view.stage.value, + "active": active, + "owner": owner, + "out_dir": out_dir, + "default_sides": default_sides, + "continuous_pacing": continuous_pacing, + "gloves": [{"side": g.side.value, "glove_id": g.glove_id} for g in view.gloves], + "prompt": prompt, + "confirm_pending": view.confirm_pending, + "elapsed_s": view.elapsed_s, + "notice": view.notice, + "outcome": _outcome(view, out_dir), + } + + +def _outcome(view: View, out_dir: str) -> dict[str, Any] | None: + """Terminal result: where files landed, or why none were saved.""" + outcome = view.outcome + if outcome is None: + return None + files = [ + {"side": f.side.value, "file": f.file, "bytes": f.bytes} for f in outcome.files + ] + directory = out_dir if files else None + return { + "kind": outcome.kind.value, + "message": outcome.message, + "directory": directory, + "files": files, + "log_tail": list(outcome.log_tail[-_LOG_LINES:]) + if outcome.kind.value in ("failed", "uncertain") + else [], + } diff --git a/src/plugins/CMakeLists.txt b/src/plugins/CMakeLists.txt index 4e5fa231f1..c26d9384c2 100644 --- a/src/plugins/CMakeLists.txt +++ b/src/plugins/CMakeLists.txt @@ -24,6 +24,7 @@ add_subdirectory(xsens_full_body) add_subdirectory(so101_leader) add_subdirectory(rebot_devarm_leader) add_subdirectory(manus) +add_subdirectory(manus_calibration) add_subdirectory(haptikos) if(BUILD_PLUGIN_NOITOM_MOCAP) diff --git a/src/plugins/manus/CMakeLists.txt b/src/plugins/manus/CMakeLists.txt index 69d34c5f44..32aa334eed 100644 --- a/src/plugins/manus/CMakeLists.txt +++ b/src/plugins/manus/CMakeLists.txt @@ -83,7 +83,8 @@ endif() # Create imported library target for Manus SDK # Note: The Manus SDK library doesn't have a SONAME, so we need to use IMPORTED_NO_SONAME -add_library(ManusSDK::ManusSDK SHARED IMPORTED) +# GLOBAL: the separate src/plugins/manus_calibration worker links the same vendor library. +add_library(ManusSDK::ManusSDK SHARED IMPORTED GLOBAL) get_filename_component(_MANUS_LIB_NAME "${_MANUS_LIB}" NAME) @@ -100,6 +101,8 @@ if(MANUS_ADD_SDK_TO_BUILD_RPATH) list(APPEND _ISAAC_MANUS_EFFECTIVE_BUILD_RPATH "${MANUS_SDK_LIBRARY_DIR}") endif() list(JOIN _ISAAC_MANUS_EFFECTIVE_BUILD_RPATH ";" _ISAAC_MANUS_EFFECTIVE_BUILD_RPATH_STR) +# For src/plugins/manus_calibration, which shares the vendor SDK's build setup but none of this plugin's code. +set(ISAAC_MANUS_SDK_BUILD_RPATH "${_ISAAC_MANUS_EFFECTIVE_BUILD_RPATH_STR}" PARENT_SCOPE) add_subdirectory(core) add_library(isaac::manus_plugin ALIAS manus_plugin_core) diff --git a/src/plugins/manus_calibration/CMakeLists.txt b/src/plugins/manus_calibration/CMakeLists.txt new file mode 100644 index 0000000000..c1597842be --- /dev/null +++ b/src/plugins/manus_calibration/CMakeLists.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# A standalone calibration process. It shares the vendor SDK's build setup with src/plugins/manus (the imported +# ManusSDK::ManusSDK target and its rpath) but none of that plugin's code. +# +# The engine and wire format need no SDK, so they build, and their tests run, without it. POSIX file and +# process calls make everything here Linux-only. +if(UNIX AND NOT APPLE) + add_subdirectory(core) + if(TARGET ManusSDK::ManusSDK) + add_subdirectory(app) + else() + add_custom_target(manus_glove_calibrator COMMAND ${CMAKE_COMMAND} -E echo "Skipping manus_glove_calibrator: the Manus SDK was not found") + endif() +endif() diff --git a/src/plugins/manus_calibration/README.md b/src/plugins/manus_calibration/README.md new file mode 100644 index 0000000000..8f01957aa4 --- /dev/null +++ b/src/plugins/manus_calibration/README.md @@ -0,0 +1,118 @@ + + +# Manus glove calibration worker + +`manus_glove_calibrator` drives the Manus SDK's glove calibration routine and exports the resulting +opaque MCAL files. It ships in the Manus plugin package as its own executable. It is not an Isaac +Capture tracking plugin: it registers no provider and publishes no device data. Tracking later +loads the exported files through `manus_hand_plugin --left-calibration=` / `--right-calibration=`. + +The worker owns the SDK connection for one calibration attempt, then exits. Stop every other Manus +consumer first (the tracking plugin, other SDK clients): the dongles are not shared. + +Calibration is a separate process by design, and lives beside the Manus plugin (`src/plugins/manus`), not +inside it. It has its own SDK adapter (`app/manus_sdk.cpp`) and shares no code with the tracking plugin +(`manus_plugin_core`), so a calibration attempt never touches the tracking pipeline. It reuses only the +vendor SDK's build setup (the `ManusSDK::ManusSDK` imported target and its rpath, defined by +`src/plugins/manus/CMakeLists.txt`) and installs with the `manus` component next to `manus_hand_plugin`, +which ships the same vendor library. The Python supervisor, wire protocol and file storage that drive it are a +standard-library-only package in [`python/`](python/), reusable by any UI or server; the browser page in +`examples/manus_calibration` is one client of it. + +## Process contract + +``` +manus_glove_calibrator --ipc-fd=N [--discovery-timeout=SECONDS] [--continuous-pacing=operator|window] +``` + +- `N` is an inherited, already-connected `AF_UNIX`/`SOCK_STREAM` socket (a `socketpair` end). The + worker reads commands from it and writes events to it. stdout/stderr carry only SDK and worker + logging and are never parsed. +- One attempt per process. After the terminal `result` event the worker exits: `0` for + `completed`/`cancelled`, `1` for `failed`. +- `SIGTERM`, `SIGHUP`, EOF on the IPC socket, and loss of the parent each request cancellation. The worker + stops the SDK calibration, shuts the SDK down, sends `result` (`cancelled`) if it still can, and + exits. The supervisor escalates to `SIGKILL` after its own bounded grace period. +- `result` is sent only after SDK shutdown has been attempted. `released` is false when the SDK calibration + could not be stopped or the SDK could not be shut down, whatever the outcome: the process then exits but + the gloves may still be in use, and a supervisor must keep them unavailable. A supervisor that never receives `result` + must treat the outcome as uncertain, never as success. + +## Wire format + +Newline-delimited UTF-8 JSON objects, at most 65536 bytes each, over the IPC socket. Every message +is an object with `"v": 1` and a string `"type"`. Unknown keys and wrong types in a command are +rejected with a `rejected` event (`reason: "invalid"`); the worker never crashes on bad input. Times +are seconds as JSON numbers. Side values are the strings `"left"` and `"right"`. + +### Commands (supervisor to worker) + +| `type` | Fields | Meaning | +| --- | --- | --- | +| `start` | `attempt` (string, 1-64 chars of `[A-Za-z0-9_-]`), `sides` (non-empty, unique, subset of `left`,`right`; processed in the order left then right), `out_dir` (absolute path of an existing directory the worker writes its `.mcal` files into) | Begin the attempt. Accepted once per process. | +| `confirm` | `attempt`, `token` | Start the SDK step offered by the `prompt` with this token. | +| `cancel` | `attempt` | Cancel the attempt. Idempotent. | + +### Events (worker to supervisor) + +| `type` | Fields | When | +| --- | --- | --- | +| `ready` | `worker_version` (string), `sdk_version` (string or null) | Once, after the IPC socket is open and before any command is read. `sdk_version` is currently always null, because the SDK is not initialized until `start`. | +| `status` | `attempt`, `phase` (`discovering`, `awaiting_step`, `capturing_step`, `saving`, `stopping`), `gloves` (array of `{side, glove_id}` discovered so far) | On every phase change. | +| `prompt` | `attempt`, `token`, `side`, `glove_id`, `index` (0-based), `count` (steps for this glove), `title`, `instruction` (the SDK text, possibly empty), `mode` (`hold`/`motion`), `duration_s`, `continuous` (bool), `estimated_s` | When a step awaits confirmation. `token` is unique per prompt within the attempt. `mode` is `motion` when the SDK reports a positive step time. Otherwise it is `hold` with `duration_s` 0; a negative SDK time (a continuous step) also sets `continuous` and puts the SDK's estimate of how long the movement lasts in `estimated_s`, which is guidance for the operator and not a completion signal. | +| `step_accepted` | `attempt`, `token` | The SDK accepted starting the step. Re-sent unchanged for a repeated `confirm` of the same token, without starting another step. | +| `step_progress` | `attempt`, `token`, `elapsed_s` | About every 0.25 s while a `motion` step samples; `elapsed_s` never exceeds `duration_s`. Elapsed sampling time, not a quality measure. | +| `rejected` | `attempt` (string or null), `command`, `reason` (`invalid`, `unknown_attempt`, `stale_token`, `unexpected`, `busy`), `message`, `token` (when relevant) | A command was refused. No state changes. | +| `exported` | `attempt`, `side`, `glove_id`, `file` (basename inside that directory), `bytes`, `sha256` (lowercase hex) | One MCAL file written and flushed. | +| `result` | `attempt` (or null), `outcome` (`completed`/`cancelled`/`failed`), `files` (array of `{side, file, bytes, sha256}`, present only for `completed`), `error` (`{code, message, side?, step?}`, present only for `failed`), `released` (boolean, always present) | Terminal. Sent exactly once, after SDK shutdown. | + +Error `code` values: `sdk_init_failed`, `no_gloves`, `glove_missing`, `glove_replaced`, +`sdk_error`, `step_failed`, `export_failed`, `protocol_error`, `internal`. The message names the +glove side and step index where known. + +## Behavior rules + +- Discover the requested sides for up to `--discovery-timeout` (default 15 s); pin each glove ID for + the attempt. A pinned glove that disappears or changes ID fails the attempt + (`glove_missing`/`glove_replaced`). +- Read step count, titles, instructions and times from the SDK; never hardcode them. Every step of a + glove is checked before the first prompt, and a non-finite time fails the attempt with `step_failed`. +- A continuous step (negative SDK time) is a hold whose prompt carries the SDK's estimate. The SDK + documents no completion signal for such steps, so the worker never claims one; whether a routine + made of them yields a usable calibration is established only by the exported files loading in + tracking. `--continuous-pacing` selects the order. `operator` (default): the operator moves, + confirms, the worker starts the SDK step and offers the next prompt at once. `window`: the operator + confirms and moves, the worker starts the SDK step, then holds it for the estimate, reporting + `step_progress` bounded by `estimated_s`, before the next prompt or the finish and export after the + last step. The window is a time estimate, not completion detection. The supervisor and the page + must be started with the same policy as the worker; the JSON schema is unchanged. +- Every prompt needs its own `confirm`, including the first prompt of the second glove. A `confirm` + that is not for the currently offered token is `rejected` (`stale_token`) and changes nothing. A + step is not skippable. +- `hold` steps: the operator holds the pose, then confirms; the SDK samples once started. + `motion` steps: confirming starts the SDK sampling window; the worker emits `step_progress` and + offers the next prompt when the window ends. +- Check both the SDK return code and the operation-result boolean on every SDK call. +- Export per glove, one glove at a time, and read the size and the bytes back to back + (`CoreSdk_GetGloveCalibrationSize(glove_id)` then `CoreSdk_GetGloveCalibration`); never overlap + left and right exports. Reject an empty export. Write `.part`, flush and fsync, then hard-link + to the final basename `_.mcal` inside `out_dir`, which therefore must support hard links (not vfat or exFAT). +- Cancellation works during discovery, while awaiting confirmation, while sampling and while + saving. After cancellation no file is reported as exported by a `completed` result. +- After `result` the worker ignores further commands without replying; it is already exiting. +- A worker blocked inside an SDK call cannot enforce its own limits. The supervisor therefore bounds every + answer only the worker can give (startup through the first prompt, a confirmed step's acceptance, the time + from the last step to the result) and terminates a silent worker; the outcome is then uncertain and + nothing is saved. The time the operator takes to confirm a prompt is never bounded. +- The worker writes each export straight into the directory it was given and never overwrites an existing + file. Removing a cancelled or failed attempt's files is the supervisor's job (see the Python adapter). + +## Build + +The target is part of the `manus` component and is built only when the Manus SDK is found, like the +rest of the plugin (`MANUS_SDK_ROOT`, see `install_manus.sh`). The step/state logic and the wire +format live in a static library with no SDK dependency so they are unit-tested without hardware; the +executable adds the real SDK binding. diff --git a/src/plugins/manus_calibration/app/CMakeLists.txt b/src/plugins/manus_calibration/app/CMakeLists.txt new file mode 100644 index 0000000000..da1c9bb66b --- /dev/null +++ b/src/plugins/manus_calibration/app/CMakeLists.txt @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +add_executable(manus_glove_calibrator + main.cpp + manus_sdk.cpp + manus_sdk.hpp +) + +# Deliberately not manus_plugin_core: calibration is its own process with its own SDK adapter, and needs no +# OpenXR or tracking pipeline. +target_link_libraries(manus_glove_calibrator + PRIVATE + manus_calibration::core + ManusSDK::ManusSDK + log_bridge::log_bridge_core +) + +set_target_properties(manus_glove_calibrator PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES + BUILD_WITH_INSTALL_RPATH NO + SKIP_BUILD_RPATH NO + BUILD_RPATH "${ISAAC_MANUS_SDK_BUILD_RPATH}" + INSTALL_RPATH "$ORIGIN/../../${CMAKE_INSTALL_LIBDIR}" +) + +install(TARGETS manus_glove_calibrator + RUNTIME DESTINATION plugins/manus + COMPONENT manus +) diff --git a/src/plugins/manus_calibration/app/main.cpp b/src/plugins/manus_calibration/app/main.cpp new file mode 100644 index 0000000000..4be20cfdc8 --- /dev/null +++ b/src/plugins/manus_calibration/app/main.cpp @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "manus_sdk.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace plugins::manus::calibration; + +namespace +{ + +constexpr const char* kWorkerVersion = "1"; +constexpr int kPollIntervalMs = 50; + +static_assert(ATOMIC_BOOL_LOCK_FREE == 2, "lock-free atomic bool is required for signal safety"); + +std::atomic g_stop_requested{ false }; + +void signal_handler(int) +{ + g_stop_requested.store(true, std::memory_order_relaxed); +} + +class SteadyClock final : public Clock +{ +public: + double now_s() override + { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + } +}; + +struct Options +{ + int ipc_fd = -1; + double discovery_timeout_s = 15.0; + ContinuousPacing continuous_pacing = ContinuousPacing::Operator; +}; + +void print_usage() +{ + // Deliberate terminal output: usage text for a person running the executable by hand. + std::fputs( + "usage: manus_glove_calibrator --ipc-fd=N [--discovery-timeout=SECONDS] " + "[--continuous-pacing=operator|window]\n", + stderr); +} + +std::optional parse_options(int argc, char** argv) +{ + Options options; + for (int i = 1; i < argc; ++i) + { + const std::string_view arg = argv[i]; + char* end = nullptr; + if (arg.rfind("--ipc-fd=", 0) == 0) + { + const std::string value(arg.substr(9)); + const long fd = std::strtol(value.c_str(), &end, 10); + if (value.empty() || *end != '\0' || fd < 3) + { + return std::nullopt; + } + options.ipc_fd = static_cast(fd); + } + else if (arg.rfind("--discovery-timeout=", 0) == 0) + { + const std::string value(arg.substr(20)); + const double seconds = std::strtod(value.c_str(), &end); + if (value.empty() || *end != '\0' || !(seconds > 0.0) || seconds > 3600.0) + { + return std::nullopt; + } + options.discovery_timeout_s = seconds; + } + else if (arg == "--continuous-pacing=operator") + { + options.continuous_pacing = ContinuousPacing::Operator; + } + else if (arg == "--continuous-pacing=window") + { + options.continuous_pacing = ContinuousPacing::Window; + } + else + { + return std::nullopt; + } + } + return options.ipc_fd >= 0 ? std::optional(options) : std::nullopt; +} + +// Newline-delimited message transport over the inherited socket. +class Ipc +{ +public: + explicit Ipc(int fd) : m_fd(fd) + { + } + + // Writes one event line. A broken peer is remembered so the caller can cancel. + void send(const Event& event) + { + if (m_broken) + { + return; + } + const std::string line = serialize_event(event) + "\n"; + size_t sent = 0; + while (sent < line.size()) + { + // MSG_NOSIGNAL: a vanished supervisor must surface as an error, not as SIGPIPE. + const ssize_t n = ::send(m_fd, line.data() + sent, line.size() - sent, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) + { + continue; + } + if (n <= 0) + { + m_broken = true; + return; + } + sent += static_cast(n); + } + } + + bool broken() const + { + return m_broken; + } + + // Reads what is available and passes each complete line to `on_line`. Returns false at end of stream. + template + bool read_lines(LineHandler&& on_line) + { + char chunk[4096]; + const ssize_t n = ::recv(m_fd, chunk, sizeof(chunk), 0); + if (n < 0) + { + return errno == EINTR || errno == EAGAIN; + } + if (n == 0) + { + return false; + } + for (ssize_t i = 0; i < n; ++i) + { + if (chunk[i] != '\n') + { + append(chunk[i], on_line); + continue; + } + if (m_discarding) + { + m_discarding = false; + } + else + { + on_line(std::string_view(m_line)); + } + m_line.clear(); + } + return true; + } + + int fd() const + { + return m_fd; + } + +private: + template + void append(char c, LineHandler& on_line) + { + if (m_discarding) + { + return; + } + m_line.push_back(c); + if (m_line.size() > kMaxMessageBytes) + { + // An oversized line is dropped whole, up to its newline, and reported once. + m_discarding = true; + m_line.assign(kMaxMessageBytes + 1, ' '); + on_line(std::string_view(m_line)); + m_line.clear(); + } + } + + int m_fd; + std::string m_line; + bool m_discarding = false; + bool m_broken = false; +}; + +} // namespace + +int main(int argc, char** argv) +{ + const std::optional options = parse_options(argc, argv); + if (!options) + { + print_usage(); + return 2; + } + const auto logger = isaaccapture::Logger::get("isaaccapture.plugins.manus.GloveCalibrator"); + + std::signal(SIGTERM, signal_handler); + std::signal(SIGINT, signal_handler); + // A dropped terminal session sends SIGHUP; cancel instead of dying with a half-written export. + std::signal(SIGHUP, signal_handler); + std::signal(SIGPIPE, SIG_IGN); + + // The supervisor owns this process: if it dies, cancel rather than keep the dongles open. + const pid_t parent = getppid(); + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() != parent) + { + g_stop_requested.store(true); + } + + ManusGloveSdk sdk; + PosixExportWriter writer; + SteadyClock clock; + Ipc ipc(options->ipc_fd); + EngineConfig config; + config.discovery_timeout_s = options->discovery_timeout_s; + config.continuous_pacing = options->continuous_pacing; + CalibrationEngine engine( + sdk, writer, clock, [&ipc](const Event& event) { ipc.send(event); }, config); + + ipc.send(ReadyEvent{ kWorkerVersion, sdk.version() }); + logger->info("Calibration worker ready"); + + bool stream_open = true; + while (!engine.finished()) + { + if (g_stop_requested.load(std::memory_order_relaxed) || ipc.broken()) + { + engine.request_cancel(); + } + + if (stream_open) + { + pollfd watched{ ipc.fd(), POLLIN, 0 }; + const int ready = ::poll(&watched, 1, kPollIntervalMs); + if (ready > 0) + { + stream_open = ipc.read_lines( + [&](std::string_view line) + { + // Nothing follows `result`: a cancel and a malformed line in one read must not + // produce a rejection after the terminal event. + if (engine.finished()) + { + return; + } + ParsedCommand parsed = parse_command(line); + if (parsed.rejection) + { + ipc.send(*parsed.rejection); + } + else if (parsed.command) + { + engine.handle(*parsed.command); + } + }); + // End of stream means the supervisor is gone: cancel and clean up. + if (!stream_open) + { + logger->warn("IPC closed by the supervisor; cancelling"); + engine.request_cancel(); + } + } + } + else + { + ::poll(nullptr, 0, kPollIntervalMs); + } + engine.tick(); + } + + logger->info("Calibration worker finished"); + return engine.outcome() == Outcome::Failed ? 1 : 0; +} diff --git a/src/plugins/manus_calibration/app/manus_sdk.cpp b/src/plugins/manus_calibration/app/manus_sdk.cpp new file mode 100644 index 0000000000..7ceaf9a40a --- /dev/null +++ b/src/plugins/manus_calibration/app/manus_sdk.cpp @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "manus_sdk.hpp" + +#include + +#include +#include +#include +#include + +namespace plugins::manus::calibration +{ + +namespace +{ + +// The landscape callback has no user-data argument, so the latest glove list lives here. +std::mutex g_landscape_mutex; +std::vector g_landscape_gloves; + +std::string code_text(SDKReturnCode code) +{ + return "SDK return code " + std::to_string(static_cast(code)); +} + +// Folds the SDK return code and the operation's own result flag into one status. +SdkStatus checked(SDKReturnCode code, bool operation_result, const char* operation) +{ + if (code != SDKReturnCode::SDKReturnCode_Success) + { + return SdkStatus::failure(std::string(operation) + " failed, " + code_text(code)); + } + if (!operation_result) + { + return SdkStatus::failure(std::string(operation) + " was refused by the SDK"); + } + return SdkStatus::success(); +} + +void on_log(LogSeverity severity, const char* text, uint32_t length) noexcept +{ + // Called from inside SDK frames: nothing may escape, and a dropped vendor line is acceptable. + try + { + static const auto logger = isaaccapture::Logger::get("isaaccapture.plugins.manus.ManusSdk"); + const std::string message(text, length); + switch (severity) + { + case LogSeverity_Debug: + logger->debug("{}", message); + break; + case LogSeverity_Warn: + logger->warn("{}", message); + break; + case LogSeverity_Error: + logger->error("{}", message); + break; + default: + logger->info("{}", message); + break; + } + } + catch (...) + { + } +} + +void on_landscape(const Landscape* landscape) noexcept +{ + if (landscape == nullptr) + { + return; + } + try + { + std::vector gloves; + const auto& list = landscape->gloveDevices; + for (uint32_t i = 0; i < list.gloveCount; ++i) + { + const GloveLandscapeData& glove = list.gloves[i]; + if (glove.side == ::Side::Side_Left || glove.side == ::Side::Side_Right) + { + gloves.push_back({ glove.side == ::Side::Side_Left ? plugins::manus::calibration::Side::Left : + plugins::manus::calibration::Side::Right, + glove.id }); + } + } + std::lock_guard lock(g_landscape_mutex); + g_landscape_gloves = std::move(gloves); + } + catch (...) + { + } +} + +// Registered with empty handlers so the SDK session has the same streams as a full Manus client. Whether the +// calibration calls depend on them is not established. +void on_skeleton(const SkeletonStreamInfo*) noexcept +{ +} + +void on_raw_device_data(const RawDeviceDataInfo*) noexcept +{ +} + +} // namespace + +ManusGloveSdk::~ManusGloveSdk() +{ + // Best effort: a destructor cannot report, and the engine has already checked the result. + (void)shutdown(); +} + +std::optional ManusGloveSdk::version() +{ + // The version query needs an initialized SDK, so the ready event reports null before a start. + if (!m_initialized) + { + return std::nullopt; + } + ManusVersion sdk_version{}; + ManusVersion core_version{}; + bool compatible = false; + if (CoreSdk_GetVersionsAndCheckCompatibility(&sdk_version, &core_version, &compatible) != + SDKReturnCode::SDKReturnCode_Success) + { + return std::nullopt; + } + return std::string(sdk_version.versionInfo, strnlen(sdk_version.versionInfo, sizeof(sdk_version.versionInfo))); +} + +SdkStatus ManusGloveSdk::initialize() +{ + // Registered first so messages emitted during initialization are captured too. + CoreSdk_RegisterCallbackForOnLog(on_log); + m_initialized = true; + const SDKReturnCode init = CoreSdk_InitializeIntegrated(); + if (init != SDKReturnCode::SDKReturnCode_Success) + { + return SdkStatus::failure("CoreSdk_InitializeIntegrated failed, " + code_text(init)); + } + + CoreSdk_RegisterCallbackForRawSkeletonStream(on_skeleton); + CoreSdk_RegisterCallbackForLandscapeStream(on_landscape); + CoreSdk_RegisterCallbackForRawDeviceDataStream(on_raw_device_data); + + // Same coordinate setup as the tracking plugin; the SDK requires one before connecting. + CoordinateSystemVUH vuh; + CoordinateSystemVUH_Init(&vuh); + vuh.handedness = ::Side::Side_Right; + vuh.up = AxisPolarity::AxisPolarity_PositiveY; + vuh.view = AxisView::AxisView_ZToViewer; + vuh.unitScale = 1.0f; + const SDKReturnCode coordinates = CoreSdk_InitializeCoordinateSystemWithVUH(vuh, true); + if (coordinates != SDKReturnCode::SDKReturnCode_Success) + { + return SdkStatus::failure("CoreSdk_InitializeCoordinateSystemWithVUH failed, " + code_text(coordinates)); + } + return SdkStatus::success(); +} + +ConnectResult ManusGloveSdk::connect() +{ + // Blocks up to one second while it listens for hosts. + if (CoreSdk_LookForHosts(1, false) != SDKReturnCode::SDKReturnCode_Success) + { + return { ConnectState::Connecting, "host lookup failed" }; + } + uint32_t host_count = 0; + if (CoreSdk_GetNumberOfAvailableHostsFound(&host_count) != SDKReturnCode::SDKReturnCode_Success || host_count == 0) + { + return { ConnectState::Connecting, "no host found yet" }; + } + std::vector hosts(host_count); + if (CoreSdk_GetAvailableHostsFound(hosts.data(), host_count) != SDKReturnCode::SDKReturnCode_Success) + { + return { ConnectState::Connecting, "cannot read the host list" }; + } + const SDKReturnCode connected = CoreSdk_ConnectToHost(hosts[0]); + if (connected == SDKReturnCode::SDKReturnCode_NotConnected) + { + return { ConnectState::Connecting, "host not connected yet" }; + } + if (connected != SDKReturnCode::SDKReturnCode_Success) + { + return { ConnectState::Failed, code_text(connected) }; + } + m_connected = true; + // Hand motion is not used by calibration; set to none as other Manus clients do once connected. + CoreSdk_SetRawSkeletonHandMotion(HandMotion_None); + return { ConnectState::Connected, {} }; +} + +std::vector ManusGloveSdk::gloves() +{ + std::lock_guard lock(g_landscape_mutex); + return g_landscape_gloves; +} + +SdkStatus ManusGloveSdk::step_count(uint32_t glove_id, uint32_t& count) +{ + GloveCalibrationArgs args{}; + args.gloveId = glove_id; + count = 0; + return checked(CoreSdk_GloveCalibrationGetNumberOfSteps(args, &count), true, "GloveCalibrationGetNumberOfSteps"); +} + +SdkStatus ManusGloveSdk::step_data(uint32_t glove_id, uint32_t index, SdkStep& step) +{ + GloveCalibrationStepArgs args{}; + args.gloveId = glove_id; + args.stepIndex = index; + GloveCalibrationStepData data{}; + const SdkStatus status = + checked(CoreSdk_GloveCalibrationGetStepData(args, &data), true, "GloveCalibrationGetStepData"); + if (!status.ok) + { + return status; + } + step.index = data.index; + // The SDK fills fixed arrays; bound the read in case a string is not terminated. + step.title.assign(data.title, strnlen(data.title, sizeof(data.title))); + step.instruction.assign(data.description, strnlen(data.description, sizeof(data.description))); + step.time_s = data.time; + return SdkStatus::success(); +} + +SdkStatus ManusGloveSdk::start(uint32_t glove_id) +{ + GloveCalibrationArgs args{}; + args.gloveId = glove_id; + bool result = false; + const SDKReturnCode code = CoreSdk_GloveCalibrationStart(args, &result); + return checked(code, result, "GloveCalibrationStart"); +} + +SdkStatus ManusGloveSdk::start_step(uint32_t glove_id, uint32_t index) +{ + GloveCalibrationStepArgs args{}; + args.gloveId = glove_id; + args.stepIndex = index; + bool result = false; + const SDKReturnCode code = CoreSdk_GloveCalibrationStartStep(args, &result); + return checked(code, result, "GloveCalibrationStartStep"); +} + +SdkStatus ManusGloveSdk::finish(uint32_t glove_id) +{ + GloveCalibrationArgs args{}; + args.gloveId = glove_id; + bool result = false; + const SDKReturnCode code = CoreSdk_GloveCalibrationFinish(args, &result); + return checked(code, result, "GloveCalibrationFinish"); +} + +SdkStatus ManusGloveSdk::stop(uint32_t glove_id) +{ + GloveCalibrationArgs args{}; + args.gloveId = glove_id; + bool result = false; + const SDKReturnCode code = CoreSdk_GloveCalibrationStop(args, &result); + return checked(code, result, "GloveCalibrationStop"); +} + +SdkStatus ManusGloveSdk::export_calibration(uint32_t glove_id, std::vector& bytes) +{ + bytes.clear(); + // The byte read below takes no glove id: it returns the glove most recently sized. + uint32_t size = 0; + const SdkStatus sized = checked(CoreSdk_GetGloveCalibrationSize(glove_id, &size), true, "GetGloveCalibrationSize"); + if (!sized.ok) + { + return sized; + } + if (size == 0) + { + return SdkStatus::failure("the SDK reports a zero-size calibration"); + } + std::vector buffer(size); + const SdkStatus read = checked(CoreSdk_GetGloveCalibration(buffer.data(), size), true, "GetGloveCalibration"); + if (!read.ok) + { + return read; + } + bytes = std::move(buffer); + return SdkStatus::success(); +} + +SdkStatus ManusGloveSdk::shutdown() +{ + if (!m_initialized) + { + return SdkStatus::success(); + } + m_initialized = false; + CoreSdk_RegisterCallbackForRawSkeletonStream(nullptr); + CoreSdk_RegisterCallbackForLandscapeStream(nullptr); + CoreSdk_RegisterCallbackForRawDeviceDataStream(nullptr); + SdkStatus status = SdkStatus::success(); + if (m_connected) + { + const SDKReturnCode disconnected = CoreSdk_Disconnect(); + m_connected = false; + if (disconnected != SDKReturnCode::SDKReturnCode_Success) + { + status = SdkStatus::failure("CoreSdk_Disconnect failed, " + code_text(disconnected)); + } + } + const SDKReturnCode down = CoreSdk_ShutDown(); + if (down != SDKReturnCode::SDKReturnCode_Success && status.ok) + { + status = SdkStatus::failure("CoreSdk_ShutDown failed, " + code_text(down)); + } + CoreSdk_RegisterCallbackForOnLog(nullptr); + std::lock_guard lock(g_landscape_mutex); + g_landscape_gloves.clear(); + return status; +} + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/app/manus_sdk.hpp b/src/plugins/manus_calibration/app/manus_sdk.hpp new file mode 100644 index 0000000000..66327b5743 --- /dev/null +++ b/src/plugins/manus_calibration/app/manus_sdk.hpp @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +namespace plugins::manus::calibration +{ + +// GloveCalibrationSdk backed by the Manus Integrated SDK. The SDK delivers its landscape through +// C callbacks, so this class owns process-wide state and at most one instance may exist. +class ManusGloveSdk final : public GloveCalibrationSdk +{ +public: + ManusGloveSdk() = default; + ~ManusGloveSdk() override; + + ManusGloveSdk(const ManusGloveSdk&) = delete; + ManusGloveSdk& operator=(const ManusGloveSdk&) = delete; + + std::optional version() override; + SdkStatus initialize() override; + ConnectResult connect() override; + std::vector gloves() override; + SdkStatus step_count(uint32_t glove_id, uint32_t& count) override; + SdkStatus step_data(uint32_t glove_id, uint32_t index, SdkStep& step) override; + SdkStatus start(uint32_t glove_id) override; + SdkStatus start_step(uint32_t glove_id, uint32_t index) override; + SdkStatus finish(uint32_t glove_id) override; + SdkStatus stop(uint32_t glove_id) override; + SdkStatus export_calibration(uint32_t glove_id, std::vector& bytes) override; + SdkStatus shutdown() override; + +private: + bool m_initialized = false; + bool m_connected = false; +}; + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/CMakeLists.txt b/src/plugins/manus_calibration/core/CMakeLists.txt new file mode 100644 index 0000000000..7d0abf0bc6 --- /dev/null +++ b/src/plugins/manus_calibration/core/CMakeLists.txt @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Wire format and step engine, with no SDK dependency so they run under test without hardware. +add_library(manus_calibration_core STATIC + engine.cpp + export_writer.cpp + json.cpp + json.hpp + protocol.cpp + sha256.cpp + inc/manus_calibration/engine.hpp + inc/manus_calibration/export_writer.hpp + inc/manus_calibration/protocol.hpp + inc/manus_calibration/sha256.hpp +) + +target_include_directories(manus_calibration_core + INTERFACE + $ +) + +set_target_properties(manus_calibration_core PROPERTIES + POSITION_INDEPENDENT_CODE ON + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES +) + +add_library(manus_calibration::core ALIAS manus_calibration_core) diff --git a/src/plugins/manus_calibration/core/engine.cpp b/src/plugins/manus_calibration/core/engine.cpp new file mode 100644 index 0000000000..0198500678 --- /dev/null +++ b/src/plugins/manus_calibration/core/engine.cpp @@ -0,0 +1,559 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/manus_calibration/engine.hpp" + +#include "inc/manus_calibration/sha256.hpp" + +#include +#include +#include + +namespace plugins::manus::calibration +{ + +namespace +{ + +std::string glove_text(Side side, uint32_t id) +{ + return std::string(side_name(side)) + " glove (id " + std::to_string(id) + ")"; +} + +} // namespace + +CalibrationEngine::CalibrationEngine( + GloveCalibrationSdk& sdk, ExportWriter& writer, Clock& clock, EventSink sink, EngineConfig config) + : m_sdk(sdk), m_writer(writer), m_clock(clock), m_sink(std::move(sink)), m_config(config) +{ +} + +void CalibrationEngine::request_cancel() noexcept +{ + m_cancel_requested.store(true, std::memory_order_relaxed); +} + +void CalibrationEngine::handle(const Command& command) +{ + if (m_finished) + { + return; + } + if (terminate_if_cancelled()) + { + return; + } + if (const auto* start = std::get_if(&command)) + { + begin_attempt(*start); + } + else if (const auto* confirm = std::get_if(&command)) + { + handle_confirm(*confirm); + } + else if (const auto* cancel = std::get_if(&command)) + { + handle_cancel(*cancel); + } +} + +void CalibrationEngine::tick() +{ + if (m_finished || terminate_if_cancelled()) + { + return; + } + switch (m_state) + { + case State::Discovering: + poll_discovery(); + break; + case State::AwaitingStep: + check_gloves(); + break; + case State::CapturingStep: + if (check_gloves()) + { + advance_capture(); + } + break; + case State::Idle: + case State::Saving: + case State::Finished: + break; + } +} + +void CalibrationEngine::reject(const std::string& command, + std::optional attempt, + RejectReason reason, + std::string message, + std::optional token) +{ + m_sink(RejectedEvent{ std::move(attempt), command, reason, std::move(message), std::move(token) }); +} + +void CalibrationEngine::emit_status(Phase phase) +{ + m_sink(StatusEvent{ m_attempt, phase, m_pinned.empty() ? m_last_reported_gloves : m_pinned }); +} + +void CalibrationEngine::begin_attempt(const StartCommand& command) +{ + if (m_state != State::Idle) + { + reject("start", command.attempt, RejectReason::Busy, "an attempt is already active"); + return; + } + if (!m_writer.directory_usable(command.out_dir)) + { + reject("start", command.attempt, RejectReason::Invalid, "out_dir is not a writable directory"); + return; + } + m_attempt = command.attempt; + m_sides = command.sides; + m_out_dir = command.out_dir; + m_state = State::Discovering; + m_discovery_started_s = m_clock.now_s(); + m_last_connect_s = m_discovery_started_s - m_config.connect_retry_s; + emit_status(Phase::Discovering); + + // Set before the call so a partially initialized SDK is still shut down on failure. + m_sdk_initialized = true; + const SdkStatus init = m_sdk.initialize(); + if (!init.ok) + { + fail(ErrorCode::SdkInitFailed, "Manus SDK initialization failed: " + init.message); + } +} + +void CalibrationEngine::poll_discovery() +{ + const double now = m_clock.now_s(); + if (!m_connected && now - m_last_connect_s >= m_config.connect_retry_s) + { + m_last_connect_s = now; + const ConnectResult connect = m_sdk.connect(); + if (connect.state == ConnectState::Failed) + { + fail(ErrorCode::SdkInitFailed, "cannot connect to the Manus host: " + connect.message); + return; + } + m_connected = (connect.state == ConnectState::Connected); + } + + std::vector found; + std::vector missing; + if (m_connected) + { + const std::vector landscape = m_sdk.gloves(); + for (const Side side : m_sides) + { + const auto glove = + std::find_if(landscape.begin(), landscape.end(), [side](const SdkGlove& g) { return g.side == side; }); + if (glove == landscape.end()) + { + missing.push_back(side); + } + else + { + found.push_back({ side, glove->id }); + } + } + } + + // Report discovery progress only when the set of visible gloves changes. + const bool changed = + found.size() != m_last_reported_gloves.size() || + !std::equal(found.begin(), found.end(), m_last_reported_gloves.begin(), + [](const GloveRef& a, const GloveRef& b) { return a.side == b.side && a.glove_id == b.glove_id; }); + if (changed) + { + m_last_reported_gloves = found; + emit_status(Phase::Discovering); + } + + if (m_connected && missing.empty()) + { + // The ids are pinned for the whole attempt: a different id later means a replaced glove. + m_pinned = found; + begin_glove(); + return; + } + if (now - m_discovery_started_s >= m_config.discovery_timeout_s) + { + std::string names; + for (const Side side : m_sides) + { + const bool seen = + std::any_of(found.begin(), found.end(), [side](const GloveRef& g) { return g.side == side; }); + if (!seen) + { + names += (names.empty() ? "" : ", "); + names += side_name(side); + } + } + fail(ErrorCode::NoGloves, "no " + names + " glove discovered within " + + std::to_string(static_cast(m_config.discovery_timeout_s)) + " s"); + } +} + +namespace +{ + +// The wire carries milliseconds. A nonzero SDK time that would round to zero is reported as one millisecond, +// so the mode, the duration and the estimate a prompt carries always agree. +double wire_seconds(double seconds) +{ + return std::max(0.001, std::round(seconds * 1000.0) / 1000.0); +} + +// Empty when the SDK's step time can be acted on; otherwise why it cannot. +std::string unusable_step_time(float time_s) +{ + if (!std::isfinite(time_s)) + { + return "the SDK reported a non-finite step time"; + } + return {}; +} + +} // namespace + +void CalibrationEngine::begin_glove() +{ + const GloveRef glove = m_pinned[m_glove_index]; + uint32_t count = 0; + const SdkStatus counted = m_sdk.step_count(glove.glove_id, count); + if (!counted.ok) + { + fail(ErrorCode::SdkError, "cannot read the calibration step count: " + counted.message, glove.side); + return; + } + if (count == 0) + { + fail(ErrorCode::SdkError, "the SDK reports no calibration steps", glove.side); + return; + } + const SdkStatus started = m_sdk.start(glove.glove_id); + if (!started.ok) + { + fail(ErrorCode::SdkError, "cannot start calibration: " + started.message, glove.side); + return; + } + m_calibrating = true; + m_step_count = count; + m_step_index = 0; + + // Check every step before the operator performs any, so an unsupported routine fails up front. + for (uint32_t index = 0; index < count; ++index) + { + SdkStep step; + const SdkStatus read = m_sdk.step_data(glove.glove_id, index, step); + if (!read.ok) + { + fail(ErrorCode::SdkError, "cannot read calibration step data: " + read.message, glove.side, index); + return; + } + if (const std::string problem = unusable_step_time(step.time_s); !problem.empty()) + { + fail(ErrorCode::StepFailed, problem, glove.side, index); + return; + } + } + offer_prompt(); +} + +void CalibrationEngine::offer_prompt() +{ + const GloveRef glove = m_pinned[m_glove_index]; + SdkStep step; + const SdkStatus read = m_sdk.step_data(glove.glove_id, m_step_index, step); + if (!read.ok) + { + fail(ErrorCode::SdkError, "cannot read calibration step data: " + read.message, glove.side, m_step_index); + return; + } + if (const std::string problem = unusable_step_time(step.time_s); !problem.empty()) + { + fail(ErrorCode::StepFailed, problem, glove.side, m_step_index); + return; + } + m_current_token = m_attempt + "-" + std::to_string(++m_prompt_seq); + // A negative time marks a continuous step with only an estimated duration. The SDK offers no way to + // detect its completion, so the estimate only paces the step: the operator moves first and the step + // ends at once (Operator), or the worker waits the estimate after the SDK accepts it (Window). + const bool continuous = step.time_s < 0.0f; + m_current_mode = step.time_s > 0.0f ? StepMode::Motion : StepMode::Hold; + m_current_duration_s = m_current_mode == StepMode::Motion ? wire_seconds(static_cast(step.time_s)) : 0.0; + const double estimated_s = continuous ? wire_seconds(-static_cast(step.time_s)) : 0.0; + const bool window_continuous = continuous && m_config.continuous_pacing == ContinuousPacing::Window; + m_pacing_s = m_current_mode == StepMode::Motion ? m_current_duration_s : (window_continuous ? estimated_s : 0.0); + m_state = State::AwaitingStep; + emit_status(Phase::AwaitingStep); + m_sink(PromptEvent{ m_attempt, m_current_token, glove.side, glove.glove_id, m_step_index, m_step_count, step.title, + step.instruction, m_current_mode, m_current_duration_s, continuous, estimated_s }); +} + +void CalibrationEngine::handle_confirm(const ConfirmCommand& command) +{ + if (m_state == State::Idle || command.attempt != m_attempt) + { + reject("confirm", command.attempt, RejectReason::UnknownAttempt, "no such attempt", command.token); + return; + } + // A repeat of an already accepted token is acknowledged again without starting another SDK step. + if (m_accepted_tokens.count(command.token) != 0) + { + m_sink(StepAcceptedEvent{ m_attempt, command.token }); + return; + } + if (m_state != State::AwaitingStep) + { + reject("confirm", command.attempt, RejectReason::Unexpected, "no step is awaiting confirmation", command.token); + return; + } + if (command.token != m_current_token) + { + reject("confirm", command.attempt, RejectReason::StaleToken, "the token does not match the offered step", + command.token); + return; + } + if (!check_gloves()) + { + return; + } + + const GloveRef glove = m_pinned[m_glove_index]; + const SdkStatus started = m_sdk.start_step(glove.glove_id, m_step_index); + if (!started.ok) + { + fail(ErrorCode::StepFailed, "the SDK refused to start the step: " + started.message, glove.side, m_step_index); + return; + } + m_accepted_tokens.insert(command.token); + m_sink(StepAcceptedEvent{ m_attempt, command.token }); + // A cancellation that arrived while the SDK call ran must not advance or export. + if (terminate_if_cancelled()) + { + return; + } + + if (m_pacing_s > 0.0) + { + m_state = State::CapturingStep; + m_step_started_s = m_clock.now_s(); + m_last_progress_s = m_step_started_s; + emit_status(Phase::CapturingStep); + m_sink(StepProgressEvent{ m_attempt, m_current_token, 0.0 }); + return; + } + step_done(); +} + +void CalibrationEngine::handle_cancel(const CancelCommand& command) +{ + if (m_state == State::Idle || command.attempt != m_attempt) + { + reject("cancel", command.attempt, RejectReason::UnknownAttempt, "no such attempt"); + return; + } + request_cancel(); + terminate_if_cancelled(); +} + +void CalibrationEngine::advance_capture() +{ + const double now = m_clock.now_s(); + const double elapsed = std::min(std::max(now - m_step_started_s, 0.0), m_pacing_s); + const bool window_over = elapsed >= m_pacing_s; + if (window_over || now - m_last_progress_s >= m_config.progress_interval_s) + { + m_last_progress_s = now; + m_sink(StepProgressEvent{ m_attempt, m_current_token, elapsed }); + } + if (window_over) + { + step_done(); + } +} + +void CalibrationEngine::step_done() +{ + ++m_step_index; + if (m_step_index < m_step_count) + { + offer_prompt(); + return; + } + finish_glove(); +} + +void CalibrationEngine::finish_glove() +{ + const GloveRef glove = m_pinned[m_glove_index]; + if (!check_gloves()) + { + return; + } + const SdkStatus finished = m_sdk.finish(glove.glove_id); + if (!finished.ok) + { + fail(ErrorCode::SdkError, "cannot finish calibration: " + finished.message, glove.side); + return; + } + m_calibrating = false; + + m_state = State::Saving; + emit_status(Phase::Saving); + if (terminate_if_cancelled()) + { + return; + } + std::vector bytes; + const SdkStatus exported = m_sdk.export_calibration(glove.glove_id, bytes); + if (!exported.ok) + { + fail(ErrorCode::ExportFailed, "cannot export the calibration: " + exported.message, glove.side); + return; + } + if (bytes.empty()) + { + fail(ErrorCode::ExportFailed, "the SDK exported an empty calibration", glove.side); + return; + } + if (terminate_if_cancelled()) + { + return; + } + const std::string basename = m_attempt + "_" + std::string(side_name(glove.side)) + ".mcal"; + if (const auto error = m_writer.write(m_out_dir, basename, bytes)) + { + fail(ErrorCode::ExportFailed, "cannot save the calibration: " + *error, glove.side); + return; + } + const ResultFile file{ glove.side, basename, bytes.size(), sha256_hex(bytes) }; + m_exported.push_back(file); + m_sink(ExportedEvent{ m_attempt, glove.side, glove.glove_id, file.file, file.bytes, file.sha256 }); + + ++m_glove_index; + if (m_glove_index < m_pinned.size()) + { + if (!terminate_if_cancelled()) + { + begin_glove(); + } + return; + } + terminate(Outcome::Completed, std::nullopt); +} + +bool CalibrationEngine::check_gloves() +{ + const std::vector landscape = m_sdk.gloves(); + for (const GloveRef& pinned : m_pinned) + { + const auto same_side = + std::find_if(landscape.begin(), landscape.end(), [&](const SdkGlove& g) { return g.side == pinned.side; }); + if (same_side == landscape.end()) + { + fail(ErrorCode::GloveMissing, glove_text(pinned.side, pinned.glove_id) + " is no longer connected", + pinned.side, m_calibrating ? std::optional(m_step_index) : std::nullopt); + return false; + } + if (same_side->id != pinned.glove_id) + { + fail(ErrorCode::GloveReplaced, + std::string(side_name(pinned.side)) + " glove changed from id " + std::to_string(pinned.glove_id) + + " to id " + std::to_string(same_side->id), + pinned.side, m_calibrating ? std::optional(m_step_index) : std::nullopt); + return false; + } + } + return true; +} + +bool CalibrationEngine::terminate_if_cancelled() +{ + if (m_finished || !m_cancel_requested.load(std::memory_order_relaxed)) + { + return m_finished; + } + terminate(Outcome::Cancelled, std::nullopt); + return true; +} + +void CalibrationEngine::fail(ErrorCode code, std::string message, std::optional side, std::optional step) +{ + terminate(Outcome::Failed, ErrorInfo{ code, std::move(message), side, step }); +} + +void CalibrationEngine::terminate(Outcome outcome, std::optional error) +{ + if (m_finished) + { + return; + } + if (!m_attempt.empty()) + { + emit_status(Phase::Stopping); + } + // Whether the gloves are known to be free, kept apart from the outcome: a failed attempt can still have + // released everything, and a failed release can follow any outcome. + bool released = true; + // An SDK calibration left running would keep the glove in calibration mode, so stop it first. If it + // cannot be stopped the cleanup is uncertain, which must not be reported as a clean cancellation. + if (m_calibrating) + { + const GloveRef glove = m_pinned[m_glove_index]; + const SdkStatus stopped = m_sdk.stop(glove.glove_id); + m_calibrating = false; + if (!stopped.ok) + { + released = false; + } + if (!stopped.ok && outcome == Outcome::Cancelled) + { + outcome = Outcome::Failed; + error = ErrorInfo{ ErrorCode::SdkError, "cannot stop calibration: " + stopped.message, glove.side, + m_step_index }; + } + } + if (m_sdk_initialized) + { + const SdkStatus down = m_sdk.shutdown(); + m_sdk_initialized = false; + if (!down.ok) + { + released = false; + } + // A completed or cancelled outcome promises the gloves are free, so a failed release downgrades it. + if (!down.ok && outcome != Outcome::Failed) + { + outcome = Outcome::Failed; + error = ErrorInfo{ ErrorCode::SdkError, "cannot shut down the SDK: " + down.message, std::nullopt, + std::nullopt }; + } + } + + m_state = State::Finished; + m_finished = true; + m_outcome = outcome; + + // Sent last, after the SDK is down. `released` says whether that cleanup succeeded; the outcome alone does not. + ResultEvent result; + result.released = released; + if (!m_attempt.empty()) + { + result.attempt = m_attempt; + } + result.outcome = outcome; + if (outcome == Outcome::Completed) + { + result.files = m_exported; + } + result.error = std::move(error); + m_sink(result); +} + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/export_writer.cpp b/src/plugins/manus_calibration/core/export_writer.cpp new file mode 100644 index 0000000000..1087d10717 --- /dev/null +++ b/src/plugins/manus_calibration/core/export_writer.cpp @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/manus_calibration/export_writer.hpp" + +#include + +#include +#include +#include +#include + +namespace plugins::manus::calibration +{ + +namespace +{ + +std::string errno_text(const std::string& what) +{ + return what + ": " + std::strerror(errno); +} + +} // namespace + +bool PosixExportWriter::directory_usable(const std::string& dir) +{ + struct stat info + { + }; + return ::stat(dir.c_str(), &info) == 0 && S_ISDIR(info.st_mode) && ::access(dir.c_str(), W_OK | X_OK) == 0; +} + +std::optional PosixExportWriter::write(const std::string& dir, + const std::string& basename, + const std::vector& bytes) +{ + // The basename is built by the engine, but a separator here would escape the output directory. + if (basename.empty() || basename.find('/') != std::string::npos) + { + return "invalid export file name"; + } + const std::string final_path = dir + "/" + basename; + const std::string part_path = final_path + ".part"; + + // O_EXCL: never overwrite an earlier file, and never write through a planted symlink. + const int fd = ::open(part_path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd < 0) + { + return errno_text("cannot create " + part_path); + } + + size_t written = 0; + while (written < bytes.size()) + { + const ssize_t n = ::write(fd, bytes.data() + written, bytes.size() - written); + if (n < 0) + { + if (errno == EINTR) + { + continue; + } + const std::string error = errno_text("cannot write " + part_path); + ::close(fd); + ::unlink(part_path.c_str()); + return error; + } + written += static_cast(n); + } + if (::fsync(fd) != 0) + { + const std::string error = errno_text("cannot flush " + part_path); + ::close(fd); + ::unlink(part_path.c_str()); + return error; + } + if (::close(fd) != 0) + { + const std::string error = errno_text("cannot close " + part_path); + ::unlink(part_path.c_str()); + return error; + } + // link() fails when the final name exists, where rename() would silently replace it. + if (::link(part_path.c_str(), final_path.c_str()) != 0) + { + const std::string error = errno_text("cannot publish " + final_path); + ::unlink(part_path.c_str()); + return error; + } + ::unlink(part_path.c_str()); + + // The new name is only durable once the directory entry is flushed. + const int dir_fd = ::open(dir.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd >= 0) + { + ::fsync(dir_fd); + ::close(dir_fd); + } + return std::nullopt; +} + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/inc/manus_calibration/engine.hpp b/src/plugins/manus_calibration/core/inc/manus_calibration/engine.hpp new file mode 100644 index 0000000000..6781ee4b82 --- /dev/null +++ b/src/plugins/manus_calibration/core/inc/manus_calibration/engine.hpp @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "export_writer.hpp" +#include "protocol.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace plugins::manus::calibration +{ + +struct SdkStatus +{ + bool ok = true; + std::string message; + + static SdkStatus success() + { + return {}; + } + + static SdkStatus failure(std::string message) + { + return { false, std::move(message) }; + } +}; + +struct SdkGlove +{ + Side side; + uint32_t id; +}; + +struct SdkStep +{ + uint32_t index = 0; + std::string title; + std::string instruction; + // The SDK's step time: zero is an instant hold, positive a timed motion. A negative value marks a + // continuous step whose duration is only an estimate; the engine paces it by the operator. + float time_s = 0.0f; +}; + +enum class ConnectState +{ + Connecting, + Connected, + Failed, +}; + +struct ConnectResult +{ + ConnectState state; + std::string message; +}; + +// The Manus calibration calls the engine needs. Every method reports failure through its return value +// (SDK return code and the operation's own result already folded together by the implementation). +class GloveCalibrationSdk +{ +public: + virtual ~GloveCalibrationSdk() = default; + + virtual std::optional version() = 0; + virtual SdkStatus initialize() = 0; + // One connection attempt; the engine calls it repeatedly until Connected or Failed. + virtual ConnectResult connect() = 0; + // Gloves currently reported by the SDK landscape. + virtual std::vector gloves() = 0; + virtual SdkStatus step_count(uint32_t glove_id, uint32_t& count) = 0; + virtual SdkStatus step_data(uint32_t glove_id, uint32_t index, SdkStep& step) = 0; + virtual SdkStatus start(uint32_t glove_id) = 0; + virtual SdkStatus start_step(uint32_t glove_id, uint32_t index) = 0; + virtual SdkStatus finish(uint32_t glove_id) = 0; + virtual SdkStatus stop(uint32_t glove_id) = 0; + // The size query and the byte read must happen back to back: the byte read has no glove argument. + virtual SdkStatus export_calibration(uint32_t glove_id, std::vector& bytes) = 0; + // Releases the SDK. A failure means the gloves may still be held, so it can never end as a completed result. + virtual SdkStatus shutdown() = 0; +}; + +class Clock +{ +public: + virtual ~Clock() = default; + // Monotonic seconds. + virtual double now_s() = 0; +}; + +// How a continuous step (negative SDK time) is paced. Operator: the operator moves first, then confirms, and the +// step ends at once. Window: confirming starts the SDK step, then the worker waits the SDK's estimate. +enum class ContinuousPacing +{ + Operator, + Window, +}; + +struct EngineConfig +{ + ContinuousPacing continuous_pacing = ContinuousPacing::Operator; + double discovery_timeout_s = 15.0; + double progress_interval_s = 0.25; + double connect_retry_s = 1.0; +}; + +// Single-threaded step machine for one calibration attempt. The owner feeds it commands and calls +// tick() regularly; the only thread-safe entry point is request_cancel(). +class CalibrationEngine +{ +public: + using EventSink = std::function; + + CalibrationEngine( + GloveCalibrationSdk& sdk, ExportWriter& writer, Clock& clock, EventSink sink, EngineConfig config = {}); + + void handle(const Command& command); + // Time-driven work: discovery polling, sampling progress, glove presence checks, pending cancellation. + void tick(); + // Safe from a signal handler or another thread; takes effect on the next tick() or handle(). + void request_cancel() noexcept; + + bool finished() const + { + return m_finished; + } + + Outcome outcome() const + { + return m_outcome; + } + +private: + enum class State + { + Idle, + Discovering, + AwaitingStep, + CapturingStep, + Saving, + Finished, + }; + + void begin_attempt(const StartCommand& command); + void handle_confirm(const ConfirmCommand& command); + void handle_cancel(const CancelCommand& command); + void poll_discovery(); + void begin_glove(); + void offer_prompt(); + void step_done(); + void finish_glove(); + void advance_capture(); + bool check_gloves(); + bool terminate_if_cancelled(); + void fail(ErrorCode code, + std::string message, + std::optional side = std::nullopt, + std::optional step = std::nullopt); + void terminate(Outcome outcome, std::optional error); + void emit_status(Phase phase); + void reject(const std::string& command, + std::optional attempt, + RejectReason reason, + std::string message, + std::optional token = std::nullopt); + + GloveCalibrationSdk& m_sdk; + ExportWriter& m_writer; + Clock& m_clock; + EventSink m_sink; + EngineConfig m_config; + + State m_state = State::Idle; + std::atomic m_cancel_requested{ false }; + bool m_finished = false; + Outcome m_outcome = Outcome::Cancelled; + + std::string m_attempt; + std::string m_out_dir; + std::vector m_sides; + std::vector m_pinned; + bool m_sdk_initialized = false; + bool m_connected = false; + double m_discovery_started_s = 0.0; + double m_last_connect_s = 0.0; + std::vector m_last_reported_gloves; + + size_t m_glove_index = 0; + bool m_calibrating = false; + uint32_t m_step_index = 0; + uint32_t m_step_count = 0; + uint64_t m_prompt_seq = 0; + std::string m_current_token; + StepMode m_current_mode = StepMode::Hold; + double m_current_duration_s = 0.0; + // How long the step is held after the SDK accepts it: the motion duration, or a continuous step's estimate + // under ContinuousPacing::Window; zero ends the step at once. + double m_pacing_s = 0.0; + double m_step_started_s = 0.0; + double m_last_progress_s = 0.0; + std::set m_accepted_tokens; + std::vector m_exported; +}; + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/inc/manus_calibration/export_writer.hpp b/src/plugins/manus_calibration/core/inc/manus_calibration/export_writer.hpp new file mode 100644 index 0000000000..96e3120a70 --- /dev/null +++ b/src/plugins/manus_calibration/core/inc/manus_calibration/export_writer.hpp @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include + +namespace plugins::manus::calibration +{ + +// Where exported MCAL bytes land. An interface so tests can inject write failures. +class ExportWriter +{ +public: + virtual ~ExportWriter() = default; + + // True when `dir` is an existing directory the worker may create files in. + virtual bool directory_usable(const std::string& dir) = 0; + + // Writes `bytes` as `dir/basename`, durably and atomically. Returns an error message on failure. + virtual std::optional write(const std::string& dir, + const std::string& basename, + const std::vector& bytes) = 0; +}; + +// Writes `.part`, fsyncs it, hard-links it to the final name (never overwriting) and fsyncs the +// directory, so a crash can never leave a truncated file under the final name. The directory must support +// hard links, which excludes vfat and exFAT. +class PosixExportWriter final : public ExportWriter +{ +public: + bool directory_usable(const std::string& dir) override; + std::optional write(const std::string& dir, + const std::string& basename, + const std::vector& bytes) override; +}; + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/inc/manus_calibration/protocol.hpp b/src/plugins/manus_calibration/core/inc/manus_calibration/protocol.hpp new file mode 100644 index 0000000000..0fb2a3ef10 --- /dev/null +++ b/src/plugins/manus_calibration/core/inc/manus_calibration/protocol.hpp @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Newline-delimited JSON wire format between the calibration worker and its supervisor. +// The field contract is documented in src/plugins/manus_calibration/README.md. +namespace plugins::manus::calibration +{ + +inline constexpr int kProtocolVersion = 1; +inline constexpr size_t kMaxMessageBytes = 65536; + +enum class Side +{ + Left, + Right, +}; + +std::string_view side_name(Side side); + +struct StartCommand +{ + std::string attempt; + // Always in processing order: left before right. + std::vector sides; + std::string out_dir; +}; + +struct ConfirmCommand +{ + std::string attempt; + std::string token; +}; + +struct CancelCommand +{ + std::string attempt; +}; + +using Command = std::variant; + +enum class RejectReason +{ + Invalid, + UnknownAttempt, + StaleToken, + Unexpected, + Busy, +}; + +enum class Phase +{ + Discovering, + AwaitingStep, + CapturingStep, + Saving, + Stopping, +}; + +enum class StepMode +{ + Hold, + Motion, +}; + +enum class Outcome +{ + Completed, + Cancelled, + Failed, +}; + +enum class ErrorCode +{ + SdkInitFailed, + NoGloves, + GloveMissing, + GloveReplaced, + SdkError, + StepFailed, + ExportFailed, + ProtocolError, + Internal, +}; + +struct GloveRef +{ + Side side; + uint32_t glove_id; +}; + +struct ReadyEvent +{ + std::string worker_version; + std::optional sdk_version; +}; + +struct StatusEvent +{ + std::string attempt; + Phase phase; + std::vector gloves; +}; + +struct PromptEvent +{ + std::string attempt; + std::string token; + Side side; + uint32_t glove_id; + uint32_t index; + uint32_t count; + std::string title; + std::string instruction; + StepMode mode; + double duration_s; + // True for an SDK step with a negative time. It is operator-paced like a hold; estimated_s is the + // SDK's estimate of how long the operator keeps the pose or movement, and zero otherwise. + bool continuous = false; + double estimated_s = 0.0; +}; + +struct StepAcceptedEvent +{ + std::string attempt; + std::string token; +}; + +struct StepProgressEvent +{ + std::string attempt; + std::string token; + double elapsed_s; +}; + +struct RejectedEvent +{ + std::optional attempt; + std::string command; + RejectReason reason; + std::string message; + std::optional token; +}; + +struct ExportedEvent +{ + std::string attempt; + Side side; + uint32_t glove_id; + std::string file; + uint64_t bytes; + std::string sha256; +}; + +struct ResultFile +{ + Side side; + std::string file; + uint64_t bytes; + std::string sha256; +}; + +struct ErrorInfo +{ + ErrorCode code; + std::string message; + std::optional side; + std::optional step; +}; + +struct ResultEvent +{ + std::optional attempt; + Outcome outcome; + std::vector files; + std::optional error; + // False when the SDK calibration could not be stopped or the SDK could not be shut down: the gloves may + // still be in use whatever the outcome says, so a supervisor must not treat them as free. + bool released = true; +}; + +using Event = + std::variant; + +// Exactly one of the two members is set. +struct ParsedCommand +{ + std::optional command; + std::optional rejection; +}; + +// Strict: unknown keys, wrong types, bad values and oversized lines all yield a rejection. +ParsedCommand parse_command(std::string_view line); + +// One JSON object without the trailing newline. +std::string serialize_event(const Event& event); + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/inc/manus_calibration/sha256.hpp b/src/plugins/manus_calibration/core/inc/manus_calibration/sha256.hpp new file mode 100644 index 0000000000..3a09cf07bd --- /dev/null +++ b/src/plugins/manus_calibration/core/inc/manus_calibration/sha256.hpp @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include + +namespace plugins::manus::calibration +{ + +// Lowercase hex SHA-256 of `data`, reported with each exported file. +std::string sha256_hex(const std::vector& data); + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/json.cpp b/src/plugins/manus_calibration/core/json.cpp new file mode 100644 index 0000000000..d505c51fa2 --- /dev/null +++ b/src/plugins/manus_calibration/core/json.cpp @@ -0,0 +1,473 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "json.hpp" + +#include +#include + +namespace plugins::manus::calibration::json +{ + +namespace +{ + +class Reader +{ +public: + explicit Reader(std::string_view text) : m_text(text) + { + } + + ParseResult run() + { + Members members; + skip_space(); + if (!consume('{')) + { + return fail("expected a JSON object"); + } + skip_space(); + if (!consume('}')) + { + while (true) + { + skip_space(); + std::string key; + if (!read_string(key)) + { + return fail(m_error.empty() ? "expected a string key" : m_error); + } + if (std::any_of(members.begin(), members.end(), [&](const auto& m) { return m.first == key; })) + { + return fail("duplicate key \"" + key + "\""); + } + skip_space(); + if (!consume(':')) + { + return fail("expected ':' after key"); + } + skip_space(); + Value value; + if (!read_value(value)) + { + return fail(m_error.empty() ? "unsupported value" : m_error); + } + members.emplace_back(std::move(key), std::move(value)); + skip_space(); + if (consume('}')) + { + break; + } + if (!consume(',')) + { + return fail("expected ',' or '}'"); + } + } + } + skip_space(); + if (m_pos != m_text.size()) + { + return fail("trailing bytes after the object"); + } + return { std::move(members), {} }; + } + +private: + static ParseResult fail(std::string message) + { + return { std::nullopt, std::move(message) }; + } + + bool consume(char expected) + { + if (m_pos < m_text.size() && m_text[m_pos] == expected) + { + ++m_pos; + return true; + } + return false; + } + + void skip_space() + { + while (m_pos < m_text.size() && + (m_text[m_pos] == ' ' || m_text[m_pos] == '\t' || m_text[m_pos] == '\r' || m_text[m_pos] == '\n')) + { + ++m_pos; + } + } + + bool read_value(Value& value) + { + if (m_pos >= m_text.size()) + { + return false; + } + const char c = m_text[m_pos]; + if (c == '"') + { + std::string s; + if (!read_string(s)) + { + return false; + } + value = std::move(s); + return true; + } + if (c == '[') + { + return read_string_array(value); + } + if (c == '-' || (c >= '0' && c <= '9')) + { + return read_integer(value); + } + m_error = "unsupported value type"; + return false; + } + + bool read_string_array(Value& value) + { + ++m_pos; + std::vector items; + skip_space(); + if (consume(']')) + { + value = std::move(items); + return true; + } + while (true) + { + skip_space(); + std::string item; + if (!read_string(item)) + { + m_error = "array elements must be strings"; + return false; + } + items.push_back(std::move(item)); + skip_space(); + if (consume(']')) + { + break; + } + if (!consume(',')) + { + m_error = "expected ',' or ']'"; + return false; + } + } + value = std::move(items); + return true; + } + + bool read_integer(Value& value) + { + const size_t start = m_pos; + consume('-'); + const size_t digits_start = m_pos; + while (m_pos < m_text.size() && m_text[m_pos] >= '0' && m_text[m_pos] <= '9') + { + ++m_pos; + } + const size_t digit_count = m_pos - digits_start; + // No fraction, exponent or leading zero; at most 18 digits keeps the value inside int64_t. + const bool leading_zero = digit_count > 1 && m_text[digits_start] == '0'; + const bool float_like = + m_pos < m_text.size() && (m_text[m_pos] == '.' || m_text[m_pos] == 'e' || m_text[m_pos] == 'E'); + if (digit_count == 0 || digit_count > 18 || leading_zero || float_like) + { + m_error = "numbers must be plain integers"; + return false; + } + int64_t parsed = 0; + for (size_t i = digits_start; i < m_pos; ++i) + { + parsed = parsed * 10 + (m_text[i] - '0'); + } + value = (m_text[start] == '-') ? -parsed : parsed; + return true; + } + + bool read_string(std::string& out) + { + if (!consume('"')) + { + return false; + } + while (m_pos < m_text.size()) + { + const unsigned char c = static_cast(m_text[m_pos++]); + if (c == '"') + { + return true; + } + if (c < 0x20) + { + m_error = "control character in string"; + return false; + } + if (c != '\\') + { + out.push_back(static_cast(c)); + continue; + } + if (!read_escape(out)) + { + return false; + } + } + m_error = "unterminated string"; + return false; + } + + bool read_escape(std::string& out) + { + if (m_pos >= m_text.size()) + { + m_error = "unterminated escape"; + return false; + } + const char e = m_text[m_pos++]; + switch (e) + { + case '"': + case '\\': + case '/': + out.push_back(e); + return true; + case 'b': + out.push_back('\b'); + return true; + case 'f': + out.push_back('\f'); + return true; + case 'n': + out.push_back('\n'); + return true; + case 'r': + out.push_back('\r'); + return true; + case 't': + out.push_back('\t'); + return true; + case 'u': + return read_unicode_escape(out); + default: + m_error = "invalid escape"; + return false; + } + } + + bool read_hex4(uint32_t& value) + { + if (m_pos + 4 > m_text.size()) + { + return false; + } + value = 0; + for (int i = 0; i < 4; ++i) + { + const char h = m_text[m_pos++]; + value <<= 4; + if (h >= '0' && h <= '9') + { + value |= uint32_t(h - '0'); + } + else if (h >= 'a' && h <= 'f') + { + value |= uint32_t(h - 'a' + 10); + } + else if (h >= 'A' && h <= 'F') + { + value |= uint32_t(h - 'A' + 10); + } + else + { + return false; + } + } + return true; + } + + bool read_unicode_escape(std::string& out) + { + uint32_t code = 0; + if (!read_hex4(code)) + { + m_error = "invalid \\u escape"; + return false; + } + if (code >= 0xD800 && code <= 0xDBFF) + { + // A high surrogate must be followed by an escaped low surrogate. + uint32_t low = 0; + if (m_pos + 2 > m_text.size() || m_text[m_pos] != '\\' || m_text[m_pos + 1] != 'u') + { + m_error = "unpaired surrogate"; + return false; + } + m_pos += 2; + if (!read_hex4(low) || low < 0xDC00 || low > 0xDFFF) + { + m_error = "unpaired surrogate"; + return false; + } + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + } + else if (code >= 0xDC00 && code <= 0xDFFF) + { + m_error = "unpaired surrogate"; + return false; + } + append_utf8(out, code); + return true; + } + + static void append_utf8(std::string& out, uint32_t code) + { + if (code < 0x80) + { + out.push_back(static_cast(code)); + } + else if (code < 0x800) + { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + else if (code < 0x10000) + { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + else + { + out.push_back(static_cast(0xF0 | (code >> 18))); + out.push_back(static_cast(0x80 | ((code >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + } + + std::string_view m_text; + size_t m_pos = 0; + std::string m_error; +}; + +// Length of the valid UTF-8 sequence starting at `text[pos]`, or 0 if the bytes there are not valid. +size_t utf8_sequence_length(std::string_view text, size_t pos) +{ + const auto byte = [&](size_t i) { return static_cast(text[i]); }; + const unsigned char lead = byte(pos); + size_t length = 0; + uint32_t min_code = 0; + uint32_t code = 0; + if (lead < 0x80) + { + return 1; + } + if ((lead & 0xE0) == 0xC0) + { + length = 2; + min_code = 0x80; + code = lead & 0x1F; + } + else if ((lead & 0xF0) == 0xE0) + { + length = 3; + min_code = 0x800; + code = lead & 0x0F; + } + else if ((lead & 0xF8) == 0xF0) + { + length = 4; + min_code = 0x10000; + code = lead & 0x07; + } + else + { + return 0; + } + if (pos + length > text.size()) + { + return 0; + } + for (size_t i = 1; i < length; ++i) + { + if ((byte(pos + i) & 0xC0) != 0x80) + { + return 0; + } + code = (code << 6) | (byte(pos + i) & 0x3F); + } + // Reject overlong forms, surrogates and values past U+10FFFF. + if (code < min_code || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) + { + return 0; + } + return length; +} + +} // namespace + +ParseResult parse_object(std::string_view text) +{ + return Reader(text).run(); +} + +void append_string(std::string& out, std::string_view text) +{ + out.push_back('"'); + size_t pos = 0; + while (pos < text.size()) + { + const size_t length = utf8_sequence_length(text, pos); + if (length == 0) + { + out += "\xEF\xBF\xBD"; + ++pos; + continue; + } + if (length > 1) + { + out.append(text.substr(pos, length)); + pos += length; + continue; + } + const char c = text[pos++]; + switch (c) + { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (static_cast(c) < 0x20) + { + char escaped[8]; + std::snprintf(escaped, sizeof(escaped), "\\u%04x", static_cast(static_cast(c))); + out += escaped; + } + else + { + out.push_back(c); + } + } + } + out.push_back('"'); +} + +} // namespace plugins::manus::calibration::json diff --git a/src/plugins/manus_calibration/core/json.hpp b/src/plugins/manus_calibration/core/json.hpp new file mode 100644 index 0000000000..d190d00be3 --- /dev/null +++ b/src/plugins/manus_calibration/core/json.hpp @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace plugins::manus::calibration::json +{ + +// The only value shapes the command protocol uses: a string, an integer, or an array of strings. +using Value = std::variant>; +using Members = std::vector>; + +struct ParseResult +{ + std::optional members; + std::string error; +}; + +// Parses one flat JSON object. Rejects duplicate keys, nested objects, floats, booleans, null and any +// trailing bytes, so a malformed command can never be half-accepted. +ParseResult parse_object(std::string_view text); + +// Appends `text` as a quoted JSON string. Bytes that are not valid UTF-8 become U+FFFD, so vendor +// strings can never produce an invalid protocol line. +void append_string(std::string& out, std::string_view text); + +} // namespace plugins::manus::calibration::json diff --git a/src/plugins/manus_calibration/core/protocol.cpp b/src/plugins/manus_calibration/core/protocol.cpp new file mode 100644 index 0000000000..1df908e91e --- /dev/null +++ b/src/plugins/manus_calibration/core/protocol.cpp @@ -0,0 +1,526 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/manus_calibration/protocol.hpp" + +#include "json.hpp" + +#include +#include +#include +#include +#include + +namespace plugins::manus::calibration +{ + +std::string_view side_name(Side side) +{ + return side == Side::Left ? "left" : "right"; +} + +namespace +{ + +constexpr size_t kMaxAttemptChars = 64; +constexpr size_t kMaxTokenChars = 128; +constexpr size_t kMaxPathChars = 4096; + +bool valid_attempt(std::string_view attempt) +{ + return !attempt.empty() && attempt.size() <= kMaxAttemptChars && + std::all_of(attempt.begin(), attempt.end(), + [](unsigned char c) { return std::isalnum(c) != 0 || c == '_' || c == '-'; }); +} + +bool valid_token(std::string_view token) +{ + return !token.empty() && token.size() <= kMaxTokenChars && + std::all_of(token.begin(), token.end(), [](unsigned char c) { return c > 0x20 && c < 0x7F; }); +} + +class FieldReader +{ +public: + explicit FieldReader(const json::Members& members) : m_members(members) + { + } + + const json::Value* find(std::string_view key) const + { + for (const auto& member : m_members) + { + if (member.first == key) + { + return &member.second; + } + } + return nullptr; + } + + // Every key must be one of `allowed`. + std::optional unknown_key(const std::set& allowed) const + { + for (const auto& member : m_members) + { + if (allowed.count(member.first) == 0) + { + return member.first; + } + } + return std::nullopt; + } + + const std::string* string_field(std::string_view key) const + { + const json::Value* value = find(key); + return value ? std::get_if(value) : nullptr; + } + +private: + const json::Members& m_members; +}; + +RejectedEvent invalid(std::string command, std::string message, std::optional attempt = std::nullopt) +{ + return RejectedEvent{ std::move(attempt), std::move(command), RejectReason::Invalid, std::move(message), + std::nullopt }; +} + +ParsedCommand reject(RejectedEvent event) +{ + return ParsedCommand{ std::nullopt, std::move(event) }; +} + +ParsedCommand accept(Command command) +{ + return ParsedCommand{ std::move(command), std::nullopt }; +} + +ParsedCommand parse_start(const FieldReader& fields) +{ + static const std::set allowed = { "v", "type", "attempt", "sides", "out_dir" }; + if (const auto unknown = fields.unknown_key(allowed)) + { + return reject(invalid("start", "unknown key \"" + *unknown + "\"")); + } + const std::string* attempt = fields.string_field("attempt"); + if (!attempt || !valid_attempt(*attempt)) + { + return reject(invalid("start", "attempt must be 1-64 characters of [A-Za-z0-9_-]")); + } + const json::Value* sides_value = fields.find("sides"); + const auto* sides_raw = sides_value ? std::get_if>(sides_value) : nullptr; + if (!sides_raw || sides_raw->empty()) + { + return reject(invalid("start", "sides must be a non-empty array", *attempt)); + } + bool left = false; + bool right = false; + for (const std::string& name : *sides_raw) + { + if (name != "left" && name != "right") + { + return reject(invalid("start", "sides entries must be \"left\" or \"right\"", *attempt)); + } + bool& seen = (name == "left") ? left : right; + if (seen) + { + return reject(invalid("start", "sides must not repeat a side", *attempt)); + } + seen = true; + } + const std::string* out_dir = fields.string_field("out_dir"); + if (!out_dir || out_dir->empty() || out_dir->size() > kMaxPathChars || (*out_dir)[0] != '/' || + out_dir->find('\0') != std::string::npos) + { + return reject(invalid("start", "out_dir must be an absolute path", *attempt)); + } + + StartCommand command; + command.attempt = *attempt; + // Fixed processing order regardless of how the supervisor listed the sides. + if (left) + { + command.sides.push_back(Side::Left); + } + if (right) + { + command.sides.push_back(Side::Right); + } + command.out_dir = *out_dir; + return accept(std::move(command)); +} + +ParsedCommand parse_confirm(const FieldReader& fields) +{ + static const std::set allowed = { "v", "type", "attempt", "token" }; + if (const auto unknown = fields.unknown_key(allowed)) + { + return reject(invalid("confirm", "unknown key \"" + *unknown + "\"")); + } + const std::string* attempt = fields.string_field("attempt"); + if (!attempt || !valid_attempt(*attempt)) + { + return reject(invalid("confirm", "attempt must be 1-64 characters of [A-Za-z0-9_-]")); + } + const std::string* token = fields.string_field("token"); + if (!token || !valid_token(*token)) + { + return reject(invalid("confirm", "token must be 1-128 printable ASCII characters", *attempt)); + } + return accept(ConfirmCommand{ *attempt, *token }); +} + +ParsedCommand parse_cancel(const FieldReader& fields) +{ + static const std::set allowed = { "v", "type", "attempt" }; + if (const auto unknown = fields.unknown_key(allowed)) + { + return reject(invalid("cancel", "unknown key \"" + *unknown + "\"")); + } + const std::string* attempt = fields.string_field("attempt"); + if (!attempt || !valid_attempt(*attempt)) + { + return reject(invalid("cancel", "attempt must be 1-64 characters of [A-Za-z0-9_-]")); + } + return accept(CancelCommand{ *attempt }); +} + +std::string_view reason_name(RejectReason reason) +{ + switch (reason) + { + case RejectReason::Invalid: + return "invalid"; + case RejectReason::UnknownAttempt: + return "unknown_attempt"; + case RejectReason::StaleToken: + return "stale_token"; + case RejectReason::Unexpected: + return "unexpected"; + case RejectReason::Busy: + return "busy"; + } + return "invalid"; +} + +std::string_view phase_name(Phase phase) +{ + switch (phase) + { + case Phase::Discovering: + return "discovering"; + case Phase::AwaitingStep: + return "awaiting_step"; + case Phase::CapturingStep: + return "capturing_step"; + case Phase::Saving: + return "saving"; + case Phase::Stopping: + return "stopping"; + } + return "stopping"; +} + +std::string_view outcome_name(Outcome outcome) +{ + switch (outcome) + { + case Outcome::Completed: + return "completed"; + case Outcome::Cancelled: + return "cancelled"; + case Outcome::Failed: + return "failed"; + } + return "failed"; +} + +std::string_view error_code_name(ErrorCode code) +{ + switch (code) + { + case ErrorCode::SdkInitFailed: + return "sdk_init_failed"; + case ErrorCode::NoGloves: + return "no_gloves"; + case ErrorCode::GloveMissing: + return "glove_missing"; + case ErrorCode::GloveReplaced: + return "glove_replaced"; + case ErrorCode::SdkError: + return "sdk_error"; + case ErrorCode::StepFailed: + return "step_failed"; + case ErrorCode::ExportFailed: + return "export_failed"; + case ErrorCode::ProtocolError: + return "protocol_error"; + case ErrorCode::Internal: + return "internal"; + } + return "internal"; +} + +// Builds one JSON object member by member; keeps every serializer free of manual comma bookkeeping. +class ObjectWriter +{ +public: + explicit ObjectWriter(std::string_view type) + { + m_out = "{\"v\":" + std::to_string(kProtocolVersion); + string("type", type); + } + + ObjectWriter& string(std::string_view key, std::string_view value) + { + key_prefix(key); + json::append_string(m_out, value); + return *this; + } + + ObjectWriter& optional_string(std::string_view key, const std::optional& value) + { + if (!value) + { + return raw(key, "null"); + } + return string(key, *value); + } + + ObjectWriter& integer(std::string_view key, uint64_t value) + { + return raw(key, std::to_string(value)); + } + + ObjectWriter& seconds(std::string_view key, double value) + { + // The engine rejects non-finite step times; this only keeps a line valid JSON, which has no NaN or infinity. + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "%.3f", std::isfinite(value) ? value : 0.0); + return raw(key, buffer); + } + + ObjectWriter& raw(std::string_view key, std::string_view value) + { + key_prefix(key); + m_out.append(value); + return *this; + } + + std::string finish() + { + m_out.push_back('}'); + return std::move(m_out); + } + +private: + void key_prefix(std::string_view key) + { + m_out.push_back(','); + json::append_string(m_out, key); + m_out.push_back(':'); + } + + std::string m_out; +}; + +std::string gloves_json(const std::vector& gloves) +{ + std::string out = "["; + for (size_t i = 0; i < gloves.size(); ++i) + { + if (i != 0) + { + out.push_back(','); + } + out += "{\"side\":"; + json::append_string(out, side_name(gloves[i].side)); + out += ",\"glove_id\":" + std::to_string(gloves[i].glove_id) + "}"; + } + out.push_back(']'); + return out; +} + +std::string files_json(const std::vector& files) +{ + std::string out = "["; + for (size_t i = 0; i < files.size(); ++i) + { + if (i != 0) + { + out.push_back(','); + } + out += "{\"side\":"; + json::append_string(out, side_name(files[i].side)); + out += ",\"file\":"; + json::append_string(out, files[i].file); + out += ",\"bytes\":" + std::to_string(files[i].bytes) + ",\"sha256\":"; + json::append_string(out, files[i].sha256); + out.push_back('}'); + } + out.push_back(']'); + return out; +} + +std::string error_json(const ErrorInfo& error) +{ + std::string out = "{\"code\":"; + json::append_string(out, error_code_name(error.code)); + out += ",\"message\":"; + json::append_string(out, error.message); + if (error.side) + { + out += ",\"side\":"; + json::append_string(out, side_name(*error.side)); + } + if (error.step) + { + out += ",\"step\":" + std::to_string(*error.step); + } + out.push_back('}'); + return out; +} + +} // namespace + +ParsedCommand parse_command(std::string_view line) +{ + if (line.size() > kMaxMessageBytes) + { + return reject(invalid("unknown", "message exceeds the size limit")); + } + const json::ParseResult parsed = json::parse_object(line); + if (!parsed.members) + { + return reject(invalid("unknown", "malformed JSON: " + parsed.error)); + } + const FieldReader fields(*parsed.members); + + const std::string* type = fields.string_field("type"); + if (!type) + { + return reject(invalid("unknown", "missing string field \"type\"")); + } + const json::Value* version = fields.find("v"); + const auto* version_number = version ? std::get_if(version) : nullptr; + if (!version_number || *version_number != kProtocolVersion) + { + return reject(invalid(*type == "start" || *type == "confirm" || *type == "cancel" ? *type : "unknown", + "unsupported or missing protocol version")); + } + if (*type == "start") + { + return parse_start(fields); + } + if (*type == "confirm") + { + return parse_confirm(fields); + } + if (*type == "cancel") + { + return parse_cancel(fields); + } + return reject(invalid("unknown", "unknown command type")); +} + +std::string serialize_event(const Event& event) +{ + struct Visitor + { + std::string operator()(const ReadyEvent& e) const + { + return ObjectWriter("ready") + .string("worker_version", e.worker_version) + .optional_string("sdk_version", e.sdk_version) + .finish(); + } + + std::string operator()(const StatusEvent& e) const + { + return ObjectWriter("status") + .string("attempt", e.attempt) + .string("phase", phase_name(e.phase)) + .raw("gloves", gloves_json(e.gloves)) + .finish(); + } + + std::string operator()(const PromptEvent& e) const + { + return ObjectWriter("prompt") + .string("attempt", e.attempt) + .string("token", e.token) + .string("side", side_name(e.side)) + .integer("glove_id", e.glove_id) + .integer("index", e.index) + .integer("count", e.count) + .string("title", e.title) + .string("instruction", e.instruction) + .string("mode", e.mode == StepMode::Motion ? "motion" : "hold") + .seconds("duration_s", e.duration_s) + .raw("continuous", e.continuous ? "true" : "false") + .seconds("estimated_s", e.estimated_s) + .finish(); + } + + std::string operator()(const StepAcceptedEvent& e) const + { + return ObjectWriter("step_accepted").string("attempt", e.attempt).string("token", e.token).finish(); + } + + std::string operator()(const StepProgressEvent& e) const + { + return ObjectWriter("step_progress") + .string("attempt", e.attempt) + .string("token", e.token) + .seconds("elapsed_s", e.elapsed_s) + .finish(); + } + + std::string operator()(const RejectedEvent& e) const + { + ObjectWriter writer("rejected"); + writer.optional_string("attempt", e.attempt) + .string("command", e.command) + .string("reason", reason_name(e.reason)) + .string("message", e.message); + if (e.token) + { + writer.string("token", *e.token); + } + return writer.finish(); + } + + std::string operator()(const ExportedEvent& e) const + { + return ObjectWriter("exported") + .string("attempt", e.attempt) + .string("side", side_name(e.side)) + .integer("glove_id", e.glove_id) + .string("file", e.file) + .integer("bytes", e.bytes) + .string("sha256", e.sha256) + .finish(); + } + + std::string operator()(const ResultEvent& e) const + { + ObjectWriter writer("result"); + writer.optional_string("attempt", e.attempt).string("outcome", outcome_name(e.outcome)); + if (e.outcome == Outcome::Completed) + { + writer.raw("files", files_json(e.files)); + } + if (e.error) + { + writer.raw("error", error_json(*e.error)); + } + writer.raw("released", e.released ? "true" : "false"); + return writer.finish(); + } + }; + return std::visit(Visitor{}, event); +} + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/core/sha256.cpp b/src/plugins/manus_calibration/core/sha256.cpp new file mode 100644 index 0000000000..7d94a0f05c --- /dev/null +++ b/src/plugins/manus_calibration/core/sha256.cpp @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/manus_calibration/sha256.hpp" + +#include +#include + +namespace plugins::manus::calibration +{ + +namespace +{ + +constexpr std::array kRoundConstants = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +}; + +uint32_t rotr(uint32_t value, unsigned bits) +{ + return (value >> bits) | (value << (32 - bits)); +} + +void compress(std::array& state, const uint8_t* block) +{ + std::array w{}; + for (size_t i = 0; i < 16; ++i) + { + w[i] = (uint32_t(block[i * 4]) << 24) | (uint32_t(block[i * 4 + 1]) << 16) | (uint32_t(block[i * 4 + 2]) << 8) | + uint32_t(block[i * 4 + 3]); + } + for (size_t i = 16; i < 64; ++i) + { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + auto v = state; + for (size_t i = 0; i < 64; ++i) + { + const uint32_t s1 = rotr(v[4], 6) ^ rotr(v[4], 11) ^ rotr(v[4], 25); + const uint32_t ch = (v[4] & v[5]) ^ (~v[4] & v[6]); + const uint32_t t1 = v[7] + s1 + ch + kRoundConstants[i] + w[i]; + const uint32_t s0 = rotr(v[0], 2) ^ rotr(v[0], 13) ^ rotr(v[0], 22); + const uint32_t maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]); + const uint32_t t2 = s0 + maj; + v[7] = v[6]; + v[6] = v[5]; + v[5] = v[4]; + v[4] = v[3] + t1; + v[3] = v[2]; + v[2] = v[1]; + v[1] = v[0]; + v[0] = t1 + t2; + } + for (size_t i = 0; i < 8; ++i) + { + state[i] += v[i]; + } +} + +} // namespace + +std::string sha256_hex(const std::vector& data) +{ + std::array state = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 }; + + // Pad to a multiple of 64 bytes: 0x80, zeros, then the bit length as a big-endian 64-bit value. + std::vector padded(data); + padded.push_back(0x80); + while (padded.size() % 64 != 56) + { + padded.push_back(0); + } + const uint64_t bit_length = uint64_t(data.size()) * 8; + for (int shift = 56; shift >= 0; shift -= 8) + { + padded.push_back(uint8_t(bit_length >> shift)); + } + + for (size_t offset = 0; offset < padded.size(); offset += 64) + { + compress(state, padded.data() + offset); + } + + static constexpr char kHex[] = "0123456789abcdef"; + std::string hex; + hex.reserve(64); + for (const uint32_t word : state) + { + for (int shift = 28; shift >= 0; shift -= 4) + { + hex.push_back(kHex[(word >> shift) & 0xf]); + } + } + return hex; +} + +} // namespace plugins::manus::calibration diff --git a/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/__init__.py b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/__init__.py new file mode 100644 index 0000000000..4687365aa0 --- /dev/null +++ b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Supervision of the Manus glove calibration worker, and storage of the MCAL files it exports. + +The worker executable is ``manus_glove_calibrator`` (see ``src/plugins/manus_calibration``). This package +has no dependencies, so a UI or a server can embed it without a vendor SDK being installed. +""" diff --git a/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/protocol.py b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/protocol.py new file mode 100644 index 0000000000..afa357ffa1 --- /dev/null +++ b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/protocol.py @@ -0,0 +1,541 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed events and commands of the calibration worker's wire protocol. + +The contract is `src/plugins/manus_calibration/README.md`. Parsing is strict: any line that is not +exactly a documented message raises :class:`ProtocolError`, so a misbehaving worker is a visible +failure rather than a skipped line. +""" + +from __future__ import annotations + +import json +import math +import re +from dataclasses import dataclass +from enum import Enum +from typing import Any + +PROTOCOL_VERSION = 1 +MAX_LINE_BYTES = 65536 + +_ATTEMPT_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") +_SHA256_RE = re.compile(r"[0-9a-f]{64}") +_FILE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") + + +class ProtocolError(Exception): + """A worker line (or a command we were asked to build) violates the contract.""" + + +class Side(str, Enum): + LEFT = "left" + RIGHT = "right" + + +class Phase(str, Enum): + DISCOVERING = "discovering" + AWAITING_STEP = "awaiting_step" + CAPTURING_STEP = "capturing_step" + SAVING = "saving" + STOPPING = "stopping" + + +class StepMode(str, Enum): + HOLD = "hold" + MOTION = "motion" + + +class Outcome(str, Enum): + COMPLETED = "completed" + CANCELLED = "cancelled" + FAILED = "failed" + + +class RejectReason(str, Enum): + INVALID = "invalid" + UNKNOWN_ATTEMPT = "unknown_attempt" + STALE_TOKEN = "stale_token" + UNEXPECTED = "unexpected" + BUSY = "busy" + + +_ERROR_CODES = frozenset( + { + "sdk_init_failed", + "no_gloves", + "glove_missing", + "glove_replaced", + "sdk_error", + "step_failed", + "export_failed", + "protocol_error", + "internal", + } +) + + +@dataclass(frozen=True) +class Glove: + side: Side + glove_id: int + + +@dataclass(frozen=True) +class ExportedFile: + """One MCAL file as the worker reports it: its basename, size and SHA-256.""" + + side: Side + file: str + bytes: int + sha256: str + + +@dataclass(frozen=True) +class WorkerError: + code: str + message: str + side: Side | None = None + step: int | None = None + + +@dataclass(frozen=True) +class Ready: + worker_version: str + sdk_version: str | None + + +@dataclass(frozen=True) +class Status: + attempt: str + phase: Phase + gloves: tuple[Glove, ...] + + +@dataclass(frozen=True) +class Prompt: + attempt: str + token: str + side: Side + glove_id: int + index: int + count: int + title: str + instruction: str + mode: StepMode + duration_s: float + # A continuous SDK step: a hold with the SDK's estimate of its length. Whether the worker waits that + # estimate after the step starts depends on its pacing policy; the estimate never signals completion. + continuous: bool = False + estimated_s: float = 0.0 + + @property + def pacing_s(self) -> float: + """The longest the step can run after it starts: its estimate when continuous, else its duration.""" + return self.estimated_s if self.continuous else self.duration_s + + +@dataclass(frozen=True) +class StepAccepted: + attempt: str + token: str + + +@dataclass(frozen=True) +class StepProgress: + attempt: str + token: str + elapsed_s: float + + +@dataclass(frozen=True) +class Rejected: + attempt: str | None + command: str + reason: RejectReason + message: str + token: str | None = None + + +@dataclass(frozen=True) +class Exported: + attempt: str + side: Side + glove_id: int + file: str + bytes: int + sha256: str + + +@dataclass(frozen=True) +class Result: + attempt: str | None + outcome: Outcome + files: tuple[ExportedFile, ...] = () + error: WorkerError | None = None + # False when the worker could not stop the SDK calibration or shut the SDK down, so the gloves may still + # be in use whatever the outcome says. + released: bool = True + + +WorkerEvent = ( + Ready | Status | Prompt | StepAccepted | StepProgress | Rejected | Exported | Result +) + + +def _fail(context: str, problem: str) -> ProtocolError: + return ProtocolError(f"{context}: {problem}") + + +def _keys( + data: dict[str, Any], required: set[str], optional: set[str], context: str +) -> None: + present = set(data) + missing = required - present + unknown = present - required - optional + if missing: + raise _fail(context, f"missing key(s) {sorted(missing)}") + if unknown: + raise _fail(context, f"unknown key(s) {sorted(unknown)}") + + +def _str( + data: dict[str, Any], key: str, context: str, *, nonempty: bool = False +) -> str: + value = data[key] + if not isinstance(value, str) or (nonempty and not value): + raise _fail( + context, f"{key} must be {'a nonempty ' if nonempty else 'a '}string" + ) + return value + + +def _opt_str(data: dict[str, Any], key: str, context: str) -> str | None: + return None if data[key] is None else _str(data, key, context) + + +def _uint(data: dict[str, Any], key: str, context: str) -> int: + value = data[key] + # bool is an int subclass; a JSON boolean must not pass as a count. + if type(value) is not int or value < 0: + raise _fail(context, f"{key} must be a nonnegative integer") + return value + + +def _seconds(data: dict[str, Any], key: str, context: str) -> float: + value = data[key] + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + raise _fail(context, f"{key} must be a finite nonnegative number") + return float(value) + + +def _enum(enum_cls: type[Enum], value: Any, context: str) -> Any: + try: + return enum_cls(value) + except ValueError: + raise _fail( + context, f"{value!r} is not one of {[m.value for m in enum_cls]}" + ) from None + + +def _attempt( + data: dict[str, Any], context: str, *, nullable: bool = False +) -> str | None: + value = data["attempt"] + if value is None and nullable: + return None + if not isinstance(value, str) or not _ATTEMPT_RE.fullmatch(value): + raise _fail(context, "attempt must be 1-64 characters of [A-Za-z0-9_-]") + return value + + +def _file_name(value: Any, context: str) -> str: + # Basename only: the supervisor joins this to its own output directory. + if not isinstance(value, str) or not _FILE_RE.fullmatch(value) or ".." in value: + raise _fail(context, f"file must be a plain basename, got {value!r}") + return value + + +def _sha(value: Any, context: str) -> str: + if not isinstance(value, str) or not _SHA256_RE.fullmatch(value): + raise _fail(context, "sha256 must be 64 lowercase hex characters") + return value + + +def _gloves(value: Any, context: str) -> tuple[Glove, ...]: + if not isinstance(value, list): + raise _fail(context, "gloves must be an array") + gloves = [] + for item in value: + if not isinstance(item, dict): + raise _fail(context, "each glove must be an object") + _keys(item, {"side", "glove_id"}, set(), f"{context}.gloves[]") + gloves.append( + Glove( + _enum(Side, item["side"], f"{context}.gloves[].side"), + _uint(item, "glove_id", f"{context}.gloves[]"), + ) + ) + sides = [g.side for g in gloves] + if len(sides) != len(set(sides)): + raise _fail(context, "gloves lists a side twice") + return tuple(gloves) + + +def _exported_files(value: Any, context: str) -> tuple[ExportedFile, ...]: + if not isinstance(value, list): + raise _fail(context, "files must be an array") + files = [] + for item in value: + if not isinstance(item, dict): + raise _fail(context, "each file must be an object") + _keys(item, {"side", "file", "bytes", "sha256"}, set(), f"{context}.files[]") + files.append( + ExportedFile( + side=_enum(Side, item["side"], f"{context}.files[].side"), + file=_file_name(item["file"], f"{context}.files[]"), + bytes=_uint(item, "bytes", f"{context}.files[]"), + sha256=_sha(item["sha256"], f"{context}.files[]"), + ) + ) + return tuple(files) + + +def _error(value: Any, context: str) -> WorkerError: + if not isinstance(value, dict): + raise _fail(context, "error must be an object") + _keys(value, {"code", "message"}, {"side", "step"}, f"{context}.error") + code = value["code"] + # isinstance first: an unhashable JSON value (a list or object) would raise TypeError in the lookup. + if not isinstance(code, str) or code not in _ERROR_CODES: + raise _fail(context, f"unknown error code {code!r}") + side = value.get("side") + step = value.get("step") + if step is not None and (type(step) is not int or step < 0): + raise _fail(context, "error.step must be a nonnegative integer") + return WorkerError( + code=code, + message=_str(value, "message", f"{context}.error"), + side=None if side is None else _enum(Side, side, f"{context}.error.side"), + step=step, + ) + + +def _parse_ready(d: dict[str, Any]) -> Ready: + _keys(d, {"v", "type", "worker_version", "sdk_version"}, set(), "ready") + return Ready( + _str(d, "worker_version", "ready"), _opt_str(d, "sdk_version", "ready") + ) + + +def _parse_status(d: dict[str, Any]) -> Status: + _keys(d, {"v", "type", "attempt", "phase", "gloves"}, set(), "status") + return Status( + _attempt(d, "status"), + _enum(Phase, d["phase"], "status.phase"), + _gloves(d["gloves"], "status"), + ) + + +def _parse_prompt(d: dict[str, Any]) -> Prompt: + keys = { + "v", + "type", + "attempt", + "token", + "side", + "glove_id", + "index", + "count", + "title", + "instruction", + "mode", + "duration_s", + "continuous", + "estimated_s", + } + _keys(d, keys, set(), "prompt") + count = _uint(d, "count", "prompt") + index = _uint(d, "index", "prompt") + if count == 0 or index >= count: + raise _fail("prompt", "index must be below a positive count") + mode = _enum(StepMode, d["mode"], "prompt.mode") + duration = _seconds(d, "duration_s", "prompt") + # Hold steps carry no sampling window; motion steps must have one. + if (mode is StepMode.HOLD) != (duration == 0): + raise _fail("prompt", "duration_s must be 0 for hold and positive for motion") + continuous = d["continuous"] + if type(continuous) is not bool: + raise _fail("prompt", "continuous must be a boolean") + estimated = _seconds(d, "estimated_s", "prompt") + # Only a hold-paced continuous step carries an estimate; a timed motion already has its window. + if continuous and (mode is not StepMode.HOLD or estimated == 0): + raise _fail("prompt", "a continuous step is a hold with a positive estimated_s") + if not continuous and estimated != 0: + raise _fail("prompt", "estimated_s must be 0 unless the step is continuous") + return Prompt( + attempt=_attempt(d, "prompt"), + token=_str(d, "token", "prompt", nonempty=True), + side=_enum(Side, d["side"], "prompt.side"), + glove_id=_uint(d, "glove_id", "prompt"), + index=index, + count=count, + title=_str(d, "title", "prompt"), + # The worker forwards the SDK description verbatim, so it may be empty. + instruction=_str(d, "instruction", "prompt"), + mode=mode, + duration_s=duration, + continuous=continuous, + estimated_s=estimated, + ) + + +def _parse_step_accepted(d: dict[str, Any]) -> StepAccepted: + _keys(d, {"v", "type", "attempt", "token"}, set(), "step_accepted") + return StepAccepted( + _attempt(d, "step_accepted"), _str(d, "token", "step_accepted", nonempty=True) + ) + + +def _parse_step_progress(d: dict[str, Any]) -> StepProgress: + _keys(d, {"v", "type", "attempt", "token", "elapsed_s"}, set(), "step_progress") + return StepProgress( + _attempt(d, "step_progress"), + _str(d, "token", "step_progress", nonempty=True), + _seconds(d, "elapsed_s", "step_progress"), + ) + + +def _parse_rejected(d: dict[str, Any]) -> Rejected: + _keys( + d, + {"v", "type", "attempt", "command", "reason", "message"}, + {"token"}, + "rejected", + ) + token = d.get("token") + if token is not None and (not isinstance(token, str) or not token): + raise _fail("rejected", "token must be a nonempty string") + return Rejected( + attempt=_attempt(d, "rejected", nullable=True), + command=_str(d, "command", "rejected", nonempty=True), + reason=_enum(RejectReason, d["reason"], "rejected.reason"), + message=_str(d, "message", "rejected"), + token=token, + ) + + +def _parse_exported(d: dict[str, Any]) -> Exported: + _keys( + d, + {"v", "type", "attempt", "side", "glove_id", "file", "bytes", "sha256"}, + set(), + "exported", + ) + return Exported( + attempt=_attempt(d, "exported"), + side=_enum(Side, d["side"], "exported.side"), + glove_id=_uint(d, "glove_id", "exported"), + file=_file_name(d["file"], "exported"), + bytes=_uint(d, "bytes", "exported"), + sha256=_sha(d["sha256"], "exported"), + ) + + +def _parse_result(d: dict[str, Any]) -> Result: + _keys( + d, {"v", "type", "attempt", "outcome", "released"}, {"files", "error"}, "result" + ) + outcome = _enum(Outcome, d["outcome"], "result.outcome") + if type(d["released"]) is not bool: + raise _fail("result", "released must be a boolean") + has_files = "files" in d + has_error = "error" in d + # `files` belongs to completed results only and `error` to failed ones. + if has_files != (outcome is Outcome.COMPLETED): + raise _fail("result", "files must be present exactly for a completed result") + if has_error != (outcome is Outcome.FAILED): + raise _fail("result", "error must be present exactly for a failed result") + return Result( + attempt=_attempt(d, "result", nullable=True), + outcome=outcome, + files=_exported_files(d["files"], "result") if has_files else (), + error=_error(d["error"], "result") if has_error else None, + released=d["released"], + ) + + +_PARSERS = { + "ready": _parse_ready, + "status": _parse_status, + "prompt": _parse_prompt, + "step_accepted": _parse_step_accepted, + "step_progress": _parse_step_progress, + "rejected": _parse_rejected, + "exported": _parse_exported, + "result": _parse_result, +} + + +def parse_event(line: bytes | str) -> WorkerEvent: + """Parse one worker line into its typed event, or raise :class:`ProtocolError`.""" + if isinstance(line, bytes): + if len(line) > MAX_LINE_BYTES: + raise ProtocolError(f"line exceeds {MAX_LINE_BYTES} bytes") + try: + line = line.decode("utf-8") + except UnicodeDecodeError as exc: + raise ProtocolError(f"line is not UTF-8: {exc}") from None + try: + data = json.loads(line) + except ValueError as exc: + raise ProtocolError(f"line is not JSON: {exc}") from None + if not isinstance(data, dict): + raise ProtocolError("line must be a JSON object") + if type(data.get("v")) is not int or data["v"] != PROTOCOL_VERSION: + raise ProtocolError(f"expected protocol version {PROTOCOL_VERSION}") + message_type = data.get("type") + # Checked before the lookup: an unhashable JSON value would raise TypeError instead of ProtocolError. + parser = _PARSERS.get(message_type) if isinstance(message_type, str) else None + if parser is None: + raise ProtocolError(f"unknown message type {message_type!r}") + return parser(data) + + +def encode_start(attempt: str, sides: tuple[Side, ...], out_dir: str) -> bytes: + """Build the `start` command line.""" + if not _ATTEMPT_RE.fullmatch(attempt): + raise ProtocolError("attempt must be 1-64 characters of [A-Za-z0-9_-]") + if not sides or len(set(sides)) != len(sides): + raise ProtocolError("sides must be a nonempty set of unique sides") + # The worker processes left before right regardless of request order. + ordered = [s.value for s in Side if s in sides] + return _line( + { + "v": PROTOCOL_VERSION, + "type": "start", + "attempt": attempt, + "sides": ordered, + "out_dir": out_dir, + } + ) + + +def encode_confirm(attempt: str, token: str) -> bytes: + return _line( + {"v": PROTOCOL_VERSION, "type": "confirm", "attempt": attempt, "token": token} + ) + + +def encode_cancel(attempt: str) -> bytes: + return _line({"v": PROTOCOL_VERSION, "type": "cancel", "attempt": attempt}) + + +def _line(message: dict[str, Any]) -> bytes: + return (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8") diff --git a/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/worker.py b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/worker.py new file mode 100644 index 0000000000..a78727dd7c --- /dev/null +++ b/src/plugins/manus_calibration/python/isaaccapture_manus_calibration/worker.py @@ -0,0 +1,856 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Supervises one run of the calibration worker executable. + +:class:`CalibrationWorker` holds the launch settings; :meth:`CalibrationWorker.new_attempt` returns +a :class:`CalibrationAttempt`, which runs the worker once and settles on exactly one +:class:`CalibrationOutcome`. Nothing here imports a UI toolkit. + +Outcome rule: the worker's own terminal ``result`` event is the only evidence of what happened. +Without one (crash, kill, EOF, protocol violation, forced termination) the outcome is +``UNCERTAIN``, never ``COMPLETED``. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import errno +import logging +import os +import secrets +import socket +import subprocess +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any + +from . import protocol as proto + +logger = logging.getLogger("isaaccapture.manus_calibration.worker") + +_LOG_LINE_CHARS = 1000 +_DRAIN_CHUNK = 4096 +_PROGRESS_SLACK_S = 1e-6 +_WATCHDOG_POLL_S = 0.25 +_TASK_SETTLE_S = 2.0 + + +class AttemptError(Exception): + """The attempt API was used in a way the attempt's state does not allow.""" + + +class ConfirmError(AttemptError): + """A confirmation was refused, by this supervisor or by the worker.""" + + +class AttemptEnded(AttemptError): + """The attempt reached its terminal outcome before the awaited event.""" + + +class AttemptState(str, Enum): + NEW = "new" + STARTING = "starting" + RUNNING = "running" + FINISHING = "finishing" + DONE = "done" + + +class OutcomeKind(str, Enum): + COMPLETED = "completed" + CANCELLED = "cancelled" + FAILED = "failed" + UNCERTAIN = "uncertain" + + +@dataclass(frozen=True) +class CalibrationOutcome: + """Terminal result of an attempt. ``files`` lists the exported files, only for ``COMPLETED``.""" + + kind: OutcomeKind + attempt: str + sides: tuple[proto.Side, ...] + message: str + files: tuple[proto.ExportedFile, ...] = () + error: proto.WorkerError | None = None + exit_code: int | None = None + forced_termination: bool = False + log_tail: tuple[str, ...] = () + + +class CalibrationWorker: + """Launch settings for the worker executable and the single-attempt-at-a-time guard.""" + + def __init__( + self, + executable: str | Path, + out_dir: str | Path, + *, + discovery_timeout_s: float = 15.0, + ready_timeout_s: float = 10.0, + cancel_grace_s: float = 10.0, + exit_grace_s: float = 5.0, + term_grace_s: float = 3.0, + kill_wait_s: float = 2.0, + log_tail_lines: int = 100, + startup_timeout_s: float | None = None, + confirm_timeout_s: float = 30.0, + finish_timeout_s: float = 60.0, + continuous_pacing: str = "operator", + ) -> None: + # Absolute, so a bare "./worker" is not re-read as a PATH lookup when the process is spawned. + if continuous_pacing not in ("operator", "window"): + raise ValueError("continuous_pacing must be 'operator' or 'window'") + # How the worker paces a continuous step; the page words its instruction to match. + self.continuous_pacing = continuous_pacing + self.executable = Path(os.path.abspath(executable)) + # The plain folder the worker writes its .mcal files into. + self.out_dir = Path(out_dir) + self.discovery_timeout_s = discovery_timeout_s + self.ready_timeout_s = ready_timeout_s + self.cancel_grace_s = cancel_grace_s + self.exit_grace_s = exit_grace_s + self.term_grace_s = term_grace_s + self.kill_wait_s = kill_wait_s + self.log_tail_lines = log_tail_lines + # Parent-side deadlines for answers only the worker can give. A worker blocked inside the SDK + # cannot enforce its own, and operator think time is deliberately never bounded. + self.startup_timeout_s = ( + discovery_timeout_s + 45.0 + if startup_timeout_s is None + else startup_timeout_s + ) + self.confirm_timeout_s = confirm_timeout_s + self.finish_timeout_s = finish_timeout_s + # Set when a worker could not be reaped: it may still hold the gloves, so nothing new may start. + self.unreleased: str | None = None + self._active: CalibrationAttempt | None = None + + def new_attempt( + self, on_event: Callable[[proto.WorkerEvent], None] | None = None + ) -> CalibrationAttempt: + """Create the attempt for one run; the worker allows only one active attempt at a time.""" + # An unreaped worker may still own the dongles, so admission stays closed until this program restarts. + if self.unreleased is not None: + raise AttemptError(self.unreleased) + if self._active is not None and self._active.state is not AttemptState.DONE: + raise AttemptError("a calibration attempt is already active") + attempt = CalibrationAttempt(self, on_event) + self._active = attempt + return attempt + + +class CalibrationAttempt: + """One worker process, one attempt, one terminal outcome.""" + + def __init__( + self, + owner: CalibrationWorker, + on_event: Callable[[proto.WorkerEvent], None] | None, + ) -> None: + self.attempt_id = f"cal-{secrets.token_hex(6)}" + self._owner = owner + self._on_event = on_event + self._state = AttemptState.NEW + self._sides: tuple[proto.Side, ...] = () + self._proc: asyncio.subprocess.Process | None = None + self._writer: asyncio.StreamWriter | None = None + self._tasks: list[asyncio.Task[Any]] = [] + self._tail: collections.deque[str] = collections.deque( + maxlen=owner.log_tail_lines + ) + self._outcome: asyncio.Future[CalibrationOutcome] = ( + asyncio.get_running_loop().create_future() + ) + + self._ready = asyncio.Event() + self._cancel_requested = asyncio.Event() + self._result_seen = asyncio.Event() + self._start_sent = False + # Why the worker refused ``start``, when it did; the worker stays idle and never reports a result. + self._start_rejection: str | None = None + self._user_cancel = False + self._reader_task: asyncio.Task[None] | None = None + self._abort_reason: str | None = None + self._violation: str | None = None + self._deadline_at: float | None = None + self._deadline_span = 0.0 + self._deadline_what = "" + self._unreaped = False + + self._result: proto.Result | None = None + self._exports: dict[proto.Side, proto.Exported] = {} + self._seen_tokens: set[str] = set() + self._prompt: proto.Prompt | None = None + self._pending_confirm: tuple[str, asyncio.Future[None]] | None = None + self._accepted_token: str | None = None + + @property + def state(self) -> AttemptState: + return self._state + + @property + def prompt(self) -> proto.Prompt | None: + """The prompt awaiting confirmation, if any.""" + return self._prompt + + @property + def sides(self) -> tuple[proto.Side, ...]: + return self._sides + + async def start(self, sides: Sequence[proto.Side]) -> None: + """Launch the worker and send ``start``. Worker-side failures settle :meth:`outcome`, not raise.""" + if self._state is not AttemptState.NEW: + raise AttemptError("an attempt can be started only once") + sides = tuple(sides) + # Validates the sides before anything is created or launched. + proto.encode_start(self.attempt_id, sides, "/") + self._sides = tuple(s for s in proto.Side if s in sides) + self._state = AttemptState.STARTING + try: + self._owner.out_dir.mkdir(parents=True, exist_ok=True) + out_dir = self._owner.out_dir.resolve(strict=True) + # An existing folder the worker cannot write would only surface as a rejected start much later. + if not os.access(out_dir, os.W_OK | os.X_OK): + raise PermissionError(errno.EACCES, "not a writable directory") + except OSError as exc: + message = f"cannot use {self._owner.out_dir}: {exc.strerror or exc}" + self._finish( + CalibrationOutcome( + OutcomeKind.FAILED, self.attempt_id, self._sides, message + ) + ) + raise AttemptError(message) from None + try: + await self._launch() + except OSError as exc: + self._finish_failed( + "spawn_failed", + f"cannot launch {self._owner.executable}: {exc.strerror or exc}", + ) + return + self._tasks.append( + asyncio.create_task(self._supervise(), name=f"{self.attempt_id}-supervise") + ) + await self._wait_ready() + if ( + self._state is AttemptState.DONE + or self._abort_reason + or self._cancel_requested.is_set() + ): + return + await self._send(proto.encode_start(self.attempt_id, self._sides, str(out_dir))) + self._start_sent = True + self._state = AttemptState.RUNNING + self._expect( + "SDK initialisation, connection and discovery", + self._owner.startup_timeout_s, + ) + + async def confirm(self, token: str) -> None: + """Start the SDK step offered under ``token``; returns when the worker accepts it. + + A repeated confirmation of a token already accepted or in flight never reaches the worker twice. + """ + if self._accepted_token == token: + return + if self._pending_confirm is not None and self._pending_confirm[0] == token: + await asyncio.shield(self._pending_confirm[1]) + return + if self._state is not AttemptState.RUNNING: + raise AttemptEnded("the attempt is not running") + if ( + self._prompt is None + or self._prompt.token != token + or self._pending_confirm is not None + ): + raise ConfirmError("that prompt is not awaiting confirmation") + done: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self._pending_confirm = (token, done) + self._expect("the SDK to start the step", self._owner.confirm_timeout_s) + await self._send(proto.encode_confirm(self.attempt_id, token)) + await asyncio.shield(done) + + async def cancel(self) -> None: + """Ask the worker to cancel. Idempotent; a result already reported is not changed.""" + if self._state is AttemptState.DONE or self._cancel_requested.is_set(): + return + if self._state is AttemptState.NEW: + self._finish( + CalibrationOutcome( + OutcomeKind.CANCELLED, + self.attempt_id, + self._sides, + "cancelled before start", + ) + ) + return + self._user_cancel = True + self._deadline_at = None + self._cancel_requested.set() + if self._start_sent: + await self._send(proto.encode_cancel(self.attempt_id)) + else: + # The worker has not been told to start, so there is nothing to wind down. + self._abort("cancelled before the worker was started") + + async def outcome(self) -> CalibrationOutcome: + """Wait for the terminal outcome.""" + return await asyncio.shield(self._outcome) + + async def _launch(self) -> None: + parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + try: + self._proc = await asyncio.create_subprocess_exec( + str(self._owner.executable), + f"--ipc-fd={child.fileno()}", + f"--discovery-timeout={self._owner.discovery_timeout_s}", + f"--continuous-pacing={self._owner.continuous_pacing}", + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(child.fileno(),), + ) + except BaseException: + parent.close() + raise + finally: + child.close() + # The line limit leaves room for the newline; longer lines surface as a protocol violation. + reader, self._writer = await asyncio.open_unix_connection( + sock=parent, limit=proto.MAX_LINE_BYTES + 1 + ) + assert self._proc.stdout is not None and self._proc.stderr is not None + self._tasks.append( + asyncio.create_task(self._drain(self._proc.stdout, "stdout")) + ) + self._tasks.append( + asyncio.create_task(self._drain(self._proc.stderr, "stderr")) + ) + self._reader_task = asyncio.create_task( + self._read_events(reader), name=f"{self.attempt_id}-reader" + ) + self._tasks.append(self._reader_task) + + async def _wait_ready(self) -> None: + """Wait for the worker's ``ready`` event; abort the worker if it does not arrive in time.""" + waiters = { + asyncio.ensure_future(self._ready.wait()), + asyncio.ensure_future(self._cancel_requested.wait()), + self._outcome, + } + try: + done, _ = await asyncio.wait( + waiters, + timeout=self._owner.ready_timeout_s, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for waiter in waiters: + if waiter is not self._outcome: + waiter.cancel() + if not done: + self._abort( + f"worker did not report ready within {self._owner.ready_timeout_s:g}s" + ) + + async def _send(self, line: bytes) -> None: + writer = self._writer + if writer is None or writer.is_closing(): + return + try: + writer.write(line) + await writer.drain() + except (ConnectionError, OSError): + # A dead worker is reported by the supervisor through its missing result. + logger.debug("worker connection closed while sending") + + async def _drain(self, stream: asyncio.StreamReader, name: str) -> None: + """Keep a bounded tail of the worker's stdout/stderr and never let a full pipe block it.""" + partial = "" + while chunk := await stream.read(_DRAIN_CHUNK): + partial += chunk.decode("utf-8", errors="replace") + *lines, partial = partial.split("\n") + for line in lines: + self._tail.append(f"{name}: {line[:_LOG_LINE_CHARS]}") + partial = partial[-_LOG_LINE_CHARS:] + if partial: + self._tail.append(f"{name}: {partial[:_LOG_LINE_CHARS]}") + + async def _read_events(self, reader: asyncio.StreamReader) -> None: + """Parse worker lines until EOF; any violation aborts the worker.""" + while True: + try: + line = await reader.readline() + except (ValueError, asyncio.LimitOverrunError): + self._violate(f"worker line exceeds {proto.MAX_LINE_BYTES} bytes") + return + except (ConnectionError, OSError): + return + if not line: + return + if not line.endswith(b"\n"): + self._violate("worker line is not newline-terminated") + return + try: + if self._result is not None: + raise proto.ProtocolError("worker sent a message after its result") + event = proto.parse_event(line[:-1]) + self._handle(event) + except proto.ProtocolError as exc: + self._violate(str(exc)) + return + + def _violate(self, message: str) -> None: + if self._violation is None: + self._violation = message + logger.warning("worker protocol violation: %s", message) + self._abort(f"protocol violation: {message}") + + def _expect(self, what: str, span: float) -> None: + """Start the clock on an answer only the worker can give.""" + self._deadline_what = what + self._deadline_span = span + self._deadline_at = asyncio.get_running_loop().time() + span + + def _touch(self) -> None: + """Any worker event proves it is alive, so a running deadline restarts.""" + if self._deadline_at is not None: + self._deadline_at = asyncio.get_running_loop().time() + self._deadline_span + + async def _watchdog(self) -> None: + """Abort when the worker stays silent past an armed deadline.""" + loop = asyncio.get_running_loop() + while True: + await asyncio.sleep(_WATCHDOG_POLL_S) + if self._deadline_at is not None and loop.time() >= self._deadline_at: + self._abort( + f"the worker did not respond within {self._deadline_span:g} s while waiting for " + f"{self._deadline_what}; it may be blocked inside the SDK" + ) + return + + def _abort(self, reason: str) -> None: + """Stop waiting for the worker: the supervisor terminates it without a grace period.""" + if self._abort_reason is None: + self._abort_reason = reason + self._cancel_requested.set() + + def _handle(self, event: proto.WorkerEvent) -> None: + """Check ``event`` against the attempt's state, then record it and notify the listener.""" + if not self._ready.is_set(): + if not isinstance(event, proto.Ready): + raise proto.ProtocolError( + f"expected ready first, got {type(event).__name__}" + ) + self._ready.set() + elif isinstance(event, proto.Ready): + raise proto.ProtocolError("ready sent twice") + elif not self._start_sent and not self._is_idle_cancel_result(event): + raise proto.ProtocolError( + f"{type(event).__name__} before the attempt was started" + ) + attempt = getattr(event, "attempt", self.attempt_id) + if attempt not in (self.attempt_id, None): + raise proto.ProtocolError( + f"event for attempt {attempt!r}, expected {self.attempt_id!r}" + ) + self._touch() + if isinstance(event, proto.Prompt): + self._on_prompt(event) + elif isinstance(event, proto.StepAccepted): + self._on_accepted(event) + elif isinstance(event, proto.StepProgress): + self._on_progress(event) + elif isinstance(event, proto.Rejected): + self._on_rejected(event) + elif isinstance(event, proto.Exported): + self._on_exported(event) + elif isinstance(event, proto.Result): + self._deadline_at = None + self._result = event + self._result_seen.set() + self._notify(event) + + def _is_idle_cancel_result(self, event: proto.WorkerEvent) -> bool: + """True for the clean result an idle worker sends when we cancelled it before ``start``.""" + return ( + self._user_cancel + and isinstance(event, proto.Result) + and event.attempt is None + and event.outcome is proto.Outcome.CANCELLED + ) + + def _on_prompt(self, prompt: proto.Prompt) -> None: + if self._prompt is not None and self._accepted_token != self._prompt.token: + raise proto.ProtocolError( + "prompt offered while the previous one is unconfirmed" + ) + if prompt.token in self._seen_tokens: + raise proto.ProtocolError("prompt token reused") + if prompt.side not in self._sides: + raise proto.ProtocolError("prompt for a side that was not requested") + self._seen_tokens.add(prompt.token) + self._prompt = prompt + # The operator's think time is unbounded. + self._deadline_at = None + + def _on_accepted(self, accepted: proto.StepAccepted) -> None: + if self._pending_confirm is None or self._pending_confirm[0] != accepted.token: + raise proto.ProtocolError("step accepted without a matching confirmation") + done = self._pending_confirm[1] + self._pending_confirm = None + self._accepted_token = accepted.token + if not done.done(): + done.set_result(None) + # The next prompt follows at once, or the worker finishes and exports after the last step. + window = self._prompt.pacing_s if self._prompt is not None else 0.0 + self._expect( + "the next step or the result", self._owner.finish_timeout_s + window + ) + + def _on_progress(self, progress: proto.StepProgress) -> None: + prompt = self._prompt + if ( + prompt is None + or progress.token != prompt.token + or self._accepted_token != prompt.token + ): + raise proto.ProtocolError("progress for a step that is not running") + if progress.elapsed_s > prompt.pacing_s + _PROGRESS_SLACK_S: + raise proto.ProtocolError("progress exceeds the step duration") + + def _on_rejected(self, rejected: proto.Rejected) -> None: + # A refused start means the worker never opened the SDK: end the attempt with its reason now. + if rejected.command == "start": + self._start_rejection = f"{rejected.reason.value}: {rejected.message}" + self._abort(f"the worker rejected start ({self._start_rejection})") + return + pending = self._pending_confirm + if ( + pending is not None + and rejected.command == "confirm" + and rejected.token in (None, pending[0]) + ): + self._pending_confirm = None + # The prompt stays offered, so we are back to waiting for the operator. + self._deadline_at = None + if not pending[1].done(): + pending[1].set_exception( + ConfirmError(f"{rejected.reason.value}: {rejected.message}") + ) + + def _on_exported(self, exported: proto.Exported) -> None: + if exported.side not in self._sides or exported.side in self._exports: + raise proto.ProtocolError( + f"unexpected export for the {exported.side.value} side" + ) + self._exports[exported.side] = exported + + def _notify(self, event: proto.WorkerEvent) -> None: + if self._on_event is None: + return + try: + self._on_event(event) + except Exception: + # A faulty listener must not stop supervision of the worker. + logger.exception("calibration event listener raised") + + async def _supervise(self) -> None: + """Run :meth:`_supervise_worker`, settling the outcome even if supervision is cancelled.""" + try: + await self._supervise_worker() + except asyncio.CancelledError: + if self._proc is not None and self._proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + self._proc.kill() + self._finish( + self._outcome_of( + OutcomeKind.UNCERTAIN, "supervision was cancelled", None, True + ) + ) + raise + + async def _supervise_worker(self) -> None: + """Wait for the worker to end (or be stopped), reap it, and settle the outcome.""" + reader = self._reader_task + assert reader is not None + cancel = asyncio.create_task(self._cancel_requested.wait()) + result = asyncio.create_task(self._result_seen.wait()) + watchdog = asyncio.create_task(self._watchdog()) + try: + await asyncio.wait( + {reader, cancel, result}, return_when=asyncio.FIRST_COMPLETED + ) + if not reader.done(): + if self._abort_reason: + grace = 0.0 + elif self._result_seen.is_set(): + grace = self._owner.exit_grace_s + else: + grace = self._owner.cancel_grace_s + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(grace): + await asyncio.shield(reader) + finally: + cancel.cancel() + result.cancel() + watchdog.cancel() + self._state = AttemptState.FINISHING + forced = await self._stop_process() + # Exit not established (even SIGKILL did not reap it): the process may still hold the gloves. + self._unreaped = self._proc is not None and self._proc.returncode is None + if not reader.done(): + reader.cancel() + await self._settle_tasks() + await self._finalize(forced) + + async def _settle_tasks(self) -> None: + """Wait a bounded time for the drain and reader tasks; a surviving process can hold their pipes open.""" + others = [t for t in self._tasks if t is not asyncio.current_task()] + if not others: + return + _done, pending = await asyncio.wait(others, timeout=_TASK_SETTLE_S) + for task in pending: + task.cancel() + + async def _stop_process(self) -> bool: + """Reap the worker, escalating to SIGTERM then SIGKILL. Returns whether it had to be forced.""" + proc = self._proc + assert proc is not None + grace = 0.0 if self._abort_reason else self._owner.exit_grace_s + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(grace): + await proc.wait() + if proc.returncode is not None: + return False + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + async with asyncio.timeout(self._owner.term_grace_s): + await proc.wait() + return True + except TimeoutError: + pass + with contextlib.suppress(ProcessLookupError): + proc.kill() + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(self._owner.kill_wait_s): + await proc.wait() + return True + + async def _finalize(self, forced: bool) -> None: + """Turn the evidence collected into the one terminal outcome, publishing files only on success.""" + proc = self._proc + code = proc.returncode if proc is not None else None + try: + outcome = await self._decide(code, forced) + except Exception as exc: + logger.exception("calibration finalization failed") + outcome = self._outcome_of( + OutcomeKind.UNCERTAIN, + f"internal error finishing the attempt: {exc}", + code, + forced, + ) + self._finish(outcome) + + async def _decide(self, code: int | None, forced: bool) -> CalibrationOutcome: + if self._unreaped: + pid = self._proc.pid if self._proc is not None else "?" + message = ( + f"the worker (pid {pid}) could not be stopped and may still be using the gloves; " + "restart this program once it has exited" + ) + self._owner.unreleased = message + return self._outcome_of( + OutcomeKind.UNCERTAIN, + message, + code, + forced, + error=proto.WorkerError("worker_unreaped", message), + ) + if self._start_rejection is not None: + # Nothing was started, so this is a clean failure rather than an unknown SDK state. + return self._outcome_of( + OutcomeKind.FAILED, + f"the worker rejected start: {self._start_rejection}", + code, + forced, + error=proto.WorkerError("start_rejected", self._start_rejection), + ) + result = self._result + if result is not None and not result.released: + # The process exited, but it reported that the SDK was not released: the gloves may still be in use. + detail = f": {result.error.message}" if result.error is not None else "" + message = ( + f"the worker could not release the SDK{detail}; restart this program once the gloves are known " + "to be free" + ) + self._owner.unreleased = message + return self._outcome_of( + OutcomeKind.UNCERTAIN, + message, + code, + forced, + error=proto.WorkerError("worker_unreleased", message), + ) + if self._violation is not None: + # Before `start` the worker has not opened the SDK, so only a started worker leaves it unknown. + kind = OutcomeKind.UNCERTAIN if self._start_sent else OutcomeKind.FAILED + return self._outcome_of( + kind, + f"worker broke the protocol ({self._violation})", + code, + forced, + error=proto.WorkerError("protocol_error", self._violation), + ) + if result is None: + if not self._start_sent: + if self._user_cancel: + return self._outcome_of( + OutcomeKind.CANCELLED, + "cancelled before the worker started", + code, + forced, + ) + return self._outcome_of( + OutcomeKind.FAILED, + self._abort_reason + or f"worker exited before it was started (exit code {code})", + code, + forced, + error=proto.WorkerError( + "worker_exit", + self._abort_reason or "worker exited before start", + ), + ) + reason = ( + self._abort_reason + or f"worker ended without reporting a result (exit code {code})" + ) + return self._outcome_of( + OutcomeKind.UNCERTAIN, + f"{reason}; the worker was stopped and its SDK state is unknown", + code, + forced, + error=proto.WorkerError("worker_exit", f"no result; exit code {code}"), + ) + if forced: + return self._outcome_of( + OutcomeKind.UNCERTAIN, + f"worker reported {result.outcome.value} but had to be terminated afterwards", + code, + forced, + ) + if result.outcome is proto.Outcome.FAILED: + assert result.error is not None + return self._outcome_of( + OutcomeKind.FAILED, + result.error.message, + code, + forced, + error=result.error, + ) + if code != 0: + return self._outcome_of( + OutcomeKind.UNCERTAIN, + f"worker reported {result.outcome.value} but exited with code {code}", + code, + forced, + ) + if result.outcome is proto.Outcome.CANCELLED: + return self._outcome_of(OutcomeKind.CANCELLED, "cancelled", code, forced) + return self._completed(result, code, forced) + + def _completed( + self, result: proto.Result, code: int | None, forced: bool + ) -> CalibrationOutcome: + """A completed result, once its files match what the worker reported exporting.""" + reported = {f.side: f for f in result.files} + exported = { + side: proto.ExportedFile(side, e.file, e.bytes, e.sha256) + for side, e in self._exports.items() + } + if reported != exported or set(reported) != set(self._sides): + return self._outcome_of( + OutcomeKind.UNCERTAIN, + "worker's completed result does not match the files it exported", + code, + forced, + error=proto.WorkerError( + "protocol_error", "result files differ from exported files" + ), + ) + return self._outcome_of( + OutcomeKind.COMPLETED, + "calibration saved", + code, + forced, + files=tuple(exported[side] for side in self._sides), + ) + + def _outcome_of( + self, + kind: OutcomeKind, + message: str, + code: int | None, + forced: bool, + *, + error: proto.WorkerError | None = None, + files: tuple[proto.ExportedFile, ...] = (), + ) -> CalibrationOutcome: + return CalibrationOutcome( + kind=kind, + attempt=self.attempt_id, + sides=self._sides, + message=message, + files=files, + error=error, + exit_code=code, + forced_termination=forced, + log_tail=tuple(self._tail), + ) + + def _remove_exports(self) -> None: + """Delete everything this attempt wrote into the folder, including a half-written file. + + The attempt id is random and prefixes every name the worker writes, so this matches no other file. + """ + for path in self._owner.out_dir.glob(f"{self.attempt_id}_*"): + with contextlib.suppress(OSError): + path.unlink() + + def _finish_failed(self, code: str, message: str) -> None: + self._finish( + self._outcome_of( + OutcomeKind.FAILED, + message, + None, + False, + error=proto.WorkerError(code, message), + ) + ) + + def _finish(self, outcome: CalibrationOutcome) -> None: + """Settle the attempt exactly once: drop partial files, fail waiters, record the outcome.""" + if self._outcome.done(): + return + # Only a completed attempt keeps files. A worker that may still be running can still write, so + # nothing is removed while it is unreaped. + if outcome.kind is not OutcomeKind.COMPLETED and not self._unreaped: + self._remove_exports() + self._state = AttemptState.DONE + self._prompt = None + if self._pending_confirm is not None: + pending = self._pending_confirm[1] + self._pending_confirm = None + if not pending.done(): + pending.set_exception( + AttemptEnded("the attempt ended before the step was accepted") + ) + self._outcome.set_result(outcome) diff --git a/src/plugins/manus_calibration/python/pyproject.toml b/src/plugins/manus_calibration/python/pyproject.toml new file mode 100644 index 0000000000..02d64af6fb --- /dev/null +++ b/src/plugins/manus_calibration/python/pyproject.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "isaaccapture-manus-calibration" +version = "0.0.0" # Internal library - not versioned +description = "Supervisor, wire protocol and MCAL storage for the Manus glove calibration worker" +requires-python = ">=3.11" +# Standard library only, so any UI or server can use it without the Manus SDK. +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["isaaccapture_manus_calibration"] diff --git a/tests/cpp/plugins/CMakeLists.txt b/tests/cpp/plugins/CMakeLists.txt index 46847194f9..fd7e2d2e68 100644 --- a/tests/cpp/plugins/CMakeLists.txt +++ b/tests/cpp/plugins/CMakeLists.txt @@ -16,3 +16,8 @@ endif() if(BUILD_PLUGIN_OGLO) add_subdirectory(oglo_tactile) endif() + +# Defined on Linux whether or not the Manus SDK is present (see src/plugins/manus_calibration/CMakeLists.txt). +if(TARGET manus_calibration_core) + add_subdirectory(manus_calibration) +endif() diff --git a/tests/cpp/plugins/manus_calibration/CMakeLists.txt b/tests/cpp/plugins/manus_calibration/CMakeLists.txt new file mode 100644 index 0000000000..2f09c8d09d --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/CMakeLists.txt @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +add_executable(manus_calibration_tests + fake_sdk.hpp + test_engine.cpp + test_export_writer.cpp + test_protocol.cpp + test_sha256.cpp +) + +target_link_libraries(manus_calibration_tests + PRIVATE + Catch2::Catch2WithMain + manus_calibration::core +) + +# A stable prefix and Catch2 tags as labels, so `ctest -R manus_calibration_` and `ctest -L unit` select the suite. +catch_discover_tests(manus_calibration_tests + TEST_PREFIX "manus_calibration_" + ADD_TAGS_AS_LABELS +) diff --git a/tests/cpp/plugins/manus_calibration/fake_sdk.hpp b/tests/cpp/plugins/manus_calibration/fake_sdk.hpp new file mode 100644 index 0000000000..dc44e75669 --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/fake_sdk.hpp @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace plugins::manus::calibration::testing +{ + +// One ordered record of SDK calls and emitted events, so tests can assert cross-cutting ordering. +using CallLog = std::vector; + +class FakeClock final : public Clock +{ +public: + double now_s() override + { + return now; + } + + void advance(double seconds) + { + now += seconds; + } + + double now = 100.0; +}; + +class MemoryWriter final : public ExportWriter +{ +public: + bool directory_usable(const std::string&) override + { + return usable; + } + + std::optional write(const std::string& dir, + const std::string& basename, + const std::vector& bytes) override + { + if (fail_with) + { + return fail_with; + } + files[dir + "/" + basename] = bytes; + return std::nullopt; + } + + bool usable = true; + std::optional fail_with; + std::map> files; +}; + +class FakeSdk final : public GloveCalibrationSdk +{ +public: + explicit FakeSdk(CallLog& log) : m_log(log) + { + } + + // Names the call that fails: initialize, connect, step_count, step_data, start, start_step, finish, stop, + // export. `fail_nth` counts occurrences of that call, starting at 1. + std::string fail_call; + int fail_nth = 1; + + std::vector landscape = { { Side::Left, 10 }, { Side::Right, 20 } }; + // Number of connect() calls that report Connecting before Connected. + int connecting_attempts = 0; + uint32_t steps = 3; + // Time of each step, indexed by step; missing entries are untimed. + std::vector step_times = { 0.0f, 2.0f, 0.0f }; + // Per-glove override of step_times, keyed by glove id. + std::map> glove_step_times; + std::vector export_bytes = { 1, 2, 3, 4, 5 }; + // What shutdown() reports; a failure models an SDK that could not be released. + SdkStatus shutdown_result = SdkStatus::success(); + std::function on_export; + std::function on_start_step; + + std::optional version() override + { + return std::string("test-sdk"); + } + + SdkStatus initialize() override + { + return step("initialize"); + } + + ConnectResult connect() override + { + record("connect"); + if (should_fail("connect")) + { + return { ConnectState::Failed, "injected" }; + } + if (connect_calls++ < connecting_attempts) + { + return { ConnectState::Connecting, "waiting" }; + } + return { ConnectState::Connected, {} }; + } + + std::vector gloves() override + { + return landscape; + } + + SdkStatus step_count(uint32_t, uint32_t& count) override + { + count = steps; + return step("step_count"); + } + + SdkStatus step_data(uint32_t glove, uint32_t index, SdkStep& out) override + { + out.index = index; + out.title = "Step " + std::to_string(index); + out.instruction = "Do pose " + std::to_string(index); + const auto override_times = glove_step_times.find(glove); + const std::vector& times = override_times != glove_step_times.end() ? override_times->second : step_times; + out.time_s = index < times.size() ? times[index] : 0.0f; + return step("step_data"); + } + + SdkStatus start(uint32_t glove) override + { + return step("start", glove); + } + + SdkStatus start_step(uint32_t glove, uint32_t index) override + { + const SdkStatus status = step("start_step", glove, index); + if (status.ok && on_start_step) + { + on_start_step(); + } + return status; + } + + SdkStatus finish(uint32_t glove) override + { + return step("finish", glove); + } + + SdkStatus stop(uint32_t glove) override + { + return step("stop", glove); + } + + SdkStatus export_calibration(uint32_t glove, std::vector& bytes) override + { + if (on_export) + { + on_export(); + } + const SdkStatus status = step("export", glove); + bytes = export_bytes; + return status; + } + + SdkStatus shutdown() override + { + record("shutdown"); + return shutdown_result; + } + + int count(const std::string& call) const + { + int n = 0; + for (const auto& entry : m_log) + { + n += entry.rfind("sdk:" + call, 0) == 0 ? 1 : 0; + } + return n; + } + +private: + void record(const std::string& call) + { + m_log.push_back("sdk:" + call); + } + + bool should_fail(const std::string& call) + { + if (call != fail_call) + { + return false; + } + return ++seen_[call] == fail_nth; + } + + SdkStatus step(const std::string& call, uint32_t glove = 0, uint32_t index = 0) + { + record(call + (glove ? ":" + std::to_string(glove) : std::string()) + + (call == "start_step" ? ":" + std::to_string(index) : std::string())); + return should_fail(call) ? SdkStatus::failure("injected") : SdkStatus::success(); + } + + CallLog& m_log; + std::map seen_; + int connect_calls = 0; +}; + +inline std::string event_name(const Event& event) +{ + struct Names + { + std::string operator()(const ReadyEvent&) const + { + return "ready"; + } + std::string operator()(const StatusEvent&) const + { + return "status"; + } + std::string operator()(const PromptEvent&) const + { + return "prompt"; + } + std::string operator()(const StepAcceptedEvent&) const + { + return "step_accepted"; + } + std::string operator()(const StepProgressEvent&) const + { + return "step_progress"; + } + std::string operator()(const RejectedEvent&) const + { + return "rejected"; + } + std::string operator()(const ExportedEvent&) const + { + return "exported"; + } + std::string operator()(const ResultEvent&) const + { + return "result"; + } + }; + return std::visit(Names{}, event); +} + +// Engine plus fakes, with every emitted event captured in order. +struct Rig +{ + Rig() : Rig(EngineConfig{}) + { + } + + explicit Rig(const EngineConfig& engine_config) : sdk(log), config(engine_config) + { + engine = std::make_unique( + sdk, writer, clock, + [this](const Event& event) + { + events.push_back(event); + log.push_back("event:" + event_name(event)); + }, + config); + } + + void send(const Command& command) + { + engine->handle(command); + } + + void start(std::vector sides = { Side::Left, Side::Right }) + { + send(StartCommand{ "att1", std::move(sides), "/stage" }); + } + + // Ticks until a prompt is offered or the engine finishes; discovery needs a few ticks. + void run_to_prompt() + { + for (int i = 0; i < 40 && !engine->finished(); ++i) + { + if (last_prompt() && !prompt_consumed()) + { + return; + } + clock.advance(0.5); + engine->tick(); + } + } + + const PromptEvent* last_prompt() const + { + for (auto it = events.rbegin(); it != events.rend(); ++it) + { + if (const auto* prompt = std::get_if(&*it)) + { + return prompt; + } + } + return nullptr; + } + + // True when the newest prompt has already been confirmed. + bool prompt_consumed() const + { + const PromptEvent* prompt = last_prompt(); + for (const Event& event : events) + { + if (const auto* accepted = std::get_if(&event)) + { + if (prompt && accepted->token == prompt->token) + { + return true; + } + } + } + return false; + } + + void confirm_current() + { + if (last_prompt() == nullptr) + { + throw std::runtime_error("no prompt was offered"); + } + send(ConfirmCommand{ "att1", last_prompt()->token }); + } + + // Confirms each freshly offered prompt and advances the clock until the engine finishes. + void run_to_end() + { + for (int i = 0; i < 500 && !engine->finished(); ++i) + { + if (last_prompt() && !prompt_consumed()) + { + confirm_current(); + } + clock.advance(0.3); + engine->tick(); + } + } + + const ResultEvent* result() const + { + for (const Event& event : events) + { + if (const auto* r = std::get_if(&event)) + { + return r; + } + } + return nullptr; + } + + std::vector rejections() const + { + std::vector out; + for (const Event& event : events) + { + if (const auto* r = std::get_if(&event)) + { + out.push_back(r); + } + } + return out; + } + + template + std::vector all() const + { + std::vector out; + for (const Event& event : events) + { + if (const auto* e = std::get_if(&event)) + { + out.push_back(e); + } + } + return out; + } + + size_t index_of(const std::string& entry) const + { + for (size_t i = 0; i < log.size(); ++i) + { + if (log[i] == entry) + { + return i; + } + } + return log.size(); + } + + CallLog log; + FakeSdk sdk; + MemoryWriter writer; + FakeClock clock; + EngineConfig config; + std::vector events; + std::unique_ptr engine; +}; + +} // namespace plugins::manus::calibration::testing diff --git a/tests/cpp/plugins/manus_calibration/test_engine.cpp b/tests/cpp/plugins/manus_calibration/test_engine.cpp new file mode 100644 index 0000000000..7a59866e2a --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/test_engine.cpp @@ -0,0 +1,1823 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "fake_sdk.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + +using namespace plugins::manus::calibration; +using namespace plugins::manus::calibration::testing; +using Catch::Matchers::ContainsSubstring; + +ConfirmCommand confirm(const std::string& token, const std::string& attempt = "att1") +{ + return ConfirmCommand{ attempt, token }; +} + +// One result, last in the stream, after exactly one SDK shutdown: the terminal contract every path must keep. +void require_clean_terminal(const Rig& rig) +{ + REQUIRE(rig.engine->finished()); + REQUIRE(rig.all().size() == 1); + REQUIRE(event_name(rig.events.back()) == "result"); + REQUIRE(rig.sdk.count("shutdown") == 1); + REQUIRE(rig.index_of("sdk:shutdown") < rig.index_of("event:result")); +} + +const ResultEvent& the_result(const Rig& rig) +{ + REQUIRE(rig.result() != nullptr); + return *rig.result(); +} + +// Confirms the current prompt, then advances time until a new prompt is offered or the engine finishes. +void confirm_and_advance(Rig& rig) +{ + const size_t prompts = rig.all().size(); + rig.confirm_current(); + for (int i = 0; i < 100 && !rig.engine->finished() && rig.all().size() == prompts; ++i) + { + rig.clock.advance(0.5); + rig.engine->tick(); + } +} + +void tick_for(Rig& rig, int ticks, double step_s = 0.3) +{ + for (int i = 0; i < ticks && !rig.engine->finished(); ++i) + { + rig.clock.advance(step_s); + rig.engine->tick(); + } +} + +// Runs until the engine finishes without any confirmation, e.g. a discovery timeout. +void tick_until_finished(Rig& rig, double step_s = 0.5) +{ + for (int i = 0; i < 200 && !rig.engine->finished(); ++i) + { + rig.clock.advance(step_s); + rig.engine->tick(); + } +} + +std::vector sdk_calls(const Rig& rig) +{ + std::vector calls; + for (const std::string& entry : rig.log) + { + if (entry.rfind("sdk:", 0) == 0) + { + calls.push_back(entry); + } + } + return calls; +} + +bool has_call(const Rig& rig, const std::string& call) +{ + const auto calls = sdk_calls(rig); + return std::find(calls.begin(), calls.end(), call) != calls.end(); +} + +} // namespace + +// --- happy paths ------------------------------------------------------------------------------------------- + +TEST_CASE("a left-only calibration completes with one exported file", "[unit][engine][flow]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Completed); + CHECK(result.attempt == std::optional("att1")); + CHECK_FALSE(result.error.has_value()); + REQUIRE(result.files.size() == 1); + CHECK(result.files[0].side == Side::Left); + CHECK(result.files[0].file == "att1_left.mcal"); + CHECK(result.files[0].bytes == rig.sdk.export_bytes.size()); + CHECK(result.files[0].sha256 == sha256_hex(rig.sdk.export_bytes)); + CHECK(rig.writer.files.at("/stage/att1_left.mcal") == rig.sdk.export_bytes); + CHECK(rig.writer.files.size() == 1); + + CHECK(rig.all().size() == 3); + CHECK(rig.all().size() == 1); + CHECK(rig.sdk.count("start:") == 1); + CHECK(rig.sdk.count("start_step") == 3); + CHECK(rig.sdk.count("finish") == 1); + CHECK(rig.sdk.count("stop") == 0); + CHECK(rig.sdk.count("export") == 1); + CHECK(rig.engine->outcome() == Outcome::Completed); +} + +TEST_CASE("a right-only calibration uses the right glove id", "[unit][engine][flow]") +{ + Rig rig; + rig.start({ Side::Right }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Completed); + REQUIRE(result.files.size() == 1); + CHECK(result.files[0].side == Side::Right); + CHECK(result.files[0].file == "att1_right.mcal"); + for (const PromptEvent* prompt : rig.all()) + { + CHECK(prompt->side == Side::Right); + CHECK(prompt->glove_id == 20); + } + CHECK(has_call(rig, "sdk:start:20")); + CHECK_FALSE(has_call(rig, "sdk:start:10")); +} + +TEST_CASE("both gloves are calibrated left then right with one file each", "[unit][engine][flow]") +{ + Rig rig; + rig.start({ Side::Left, Side::Right }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Completed); + REQUIRE(result.files.size() == 2); + CHECK(result.files[0].side == Side::Left); + CHECK(result.files[1].side == Side::Right); + CHECK(rig.writer.files.count("/stage/att1_left.mcal") == 1); + CHECK(rig.writer.files.count("/stage/att1_right.mcal") == 1); + + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 6); + for (size_t i = 0; i < prompts.size(); ++i) + { + CHECK(prompts[i]->side == (i < 3 ? Side::Left : Side::Right)); + CHECK(prompts[i]->glove_id == (i < 3 ? 10U : 20U)); + CHECK(prompts[i]->index == i % 3); + CHECK(prompts[i]->count == 3); + } + + // Export of the first glove finishes before the second glove starts calibrating. + CHECK(rig.index_of("sdk:export:10") < rig.index_of("sdk:start:20")); + CHECK(rig.index_of("sdk:finish:10") < rig.index_of("sdk:export:10")); + CHECK(rig.index_of("sdk:finish:20") < rig.index_of("sdk:export:20")); + CHECK(rig.index_of("sdk:start:10") < rig.index_of("sdk:start:20")); +} + +TEST_CASE("prompts carry the SDK step data and distinguish hold from motion", "[unit][engine][flow]") +{ + Rig rig; + rig.sdk.step_times = { 0.0f, 2.0f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + const PromptEvent* first = rig.last_prompt(); + REQUIRE(first != nullptr); + CHECK(first->attempt == "att1"); + CHECK(first->title == "Step 0"); + CHECK(first->instruction == "Do pose 0"); + CHECK(first->mode == StepMode::Hold); + CHECK(first->duration_s == 0.0); + CHECK(first->index == 0); + CHECK(first->count == 3); + + confirm_and_advance(rig); + const PromptEvent* second = rig.last_prompt(); + CHECK(second->index == 1); + CHECK(second->mode == StepMode::Motion); + CHECK(second->duration_s == 2.0); + + confirm_and_advance(rig); + const PromptEvent* third = rig.last_prompt(); + CHECK(third->index == 2); + CHECK(third->mode == StepMode::Hold); + CHECK(third->duration_s == 0.0); +} + +TEST_CASE("a zero step time is a hold and a positive one is a motion with that duration", "[unit][engine][flow]") +{ + Rig rig; + rig.sdk.steps = 4; + rig.sdk.step_times = { 0.0f, 0.5f, 0.001f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 4); + CHECK(prompts[0]->mode == StepMode::Hold); + CHECK(prompts[0]->duration_s == 0.0); + CHECK(prompts[1]->mode == StepMode::Motion); + CHECK(prompts[1]->duration_s == 0.5); + CHECK(prompts[2]->mode == StepMode::Motion); + CHECK(prompts[2]->duration_s == 0.001); + CHECK(prompts[3]->mode == StepMode::Hold); + CHECK(prompts[3]->duration_s == 0.0); + for (const PromptEvent* prompt : prompts) + { + CHECK_FALSE(prompt->continuous); + CHECK(prompt->estimated_s == 0.0); + } +} + +TEST_CASE("a step time below one millisecond is reported as one millisecond", "[unit][engine][flow]") +{ + Rig rig; + rig.sdk.steps = 2; + rig.sdk.step_times = { 0.0004f, -0.0004f }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 2); + // The mode, the duration and the estimate must agree after the wire rounds to milliseconds. + CHECK(prompts[0]->mode == StepMode::Motion); + CHECK(prompts[0]->duration_s == 0.001); + CHECK(prompts[1]->continuous); + CHECK(prompts[1]->estimated_s == 0.001); +} + +TEST_CASE("a negative step time is a continuous hold carrying the SDK estimate", "[unit][engine][flow][continuous]") +{ + Rig rig; + rig.sdk.step_times = { -1.0f, -0.001f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + const PromptEvent* first = rig.last_prompt(); + REQUIRE(first != nullptr); + CHECK(first->mode == StepMode::Hold); + CHECK(first->duration_s == 0.0); + CHECK(first->continuous); + CHECK(first->estimated_s == 1.0); + CHECK(rig.sdk.count("start_step") == 0); + + // Confirming starts the SDK step once, and the next prompt follows with no sampling window in between. + rig.confirm_current(); + rig.engine->tick(); + CHECK(rig.sdk.count("start_step") == 1); + CHECK(rig.all().empty()); + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 2); + CHECK(prompts[1]->index == 1); + CHECK(prompts[1]->mode == StepMode::Hold); + CHECK(prompts[1]->continuous); + CHECK(prompts[1]->estimated_s == 0.001); + + // A zero-time step after continuous ones is a plain hold again. + confirm_and_advance(rig); + const PromptEvent* third = rig.last_prompt(); + CHECK(third->index == 2); + CHECK_FALSE(third->continuous); + CHECK(third->estimated_s == 0.0); +} + +TEST_CASE("a routine of all continuous steps, like the real gloves report, completes with both files", + "[unit][engine][flow][continuous]") +{ + const std::vector real_times = { -2.0f, -2.0f, -10.0f, -5.0f, -3.0f, -3.0f, -3.0f, -3.0f }; + Rig rig; + rig.sdk.steps = 8; + rig.sdk.step_times = real_times; + rig.start({ Side::Left, Side::Right }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Completed); + REQUIRE(result.files.size() == 2); + CHECK(rig.writer.files.count("/stage/att1_left.mcal") == 1); + CHECK(rig.writer.files.count("/stage/att1_right.mcal") == 1); + + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 16); + for (size_t i = 0; i < prompts.size(); ++i) + { + CHECK(prompts[i]->mode == StepMode::Hold); + CHECK(prompts[i]->duration_s == 0.0); + CHECK(prompts[i]->continuous); + CHECK(prompts[i]->estimated_s == -static_cast(real_times[i % 8])); + } + // Every step is started exactly once and no sampling window is reported. + CHECK(rig.sdk.count("start_step") == 16); + CHECK(rig.all().empty()); + CHECK(rig.all().size() == 16); +} + +// --- continuous steps under window pacing ----------------------------------------------------------------- + +namespace +{ + +EngineConfig window_pacing() +{ + EngineConfig config; + config.continuous_pacing = ContinuousPacing::Window; + return config; +} + +} // namespace + +TEST_CASE("window pacing holds a continuous step for its estimate after the SDK starts it", + "[unit][engine][flow][continuous][window]") +{ + Rig rig(window_pacing()); + rig.sdk.steps = 2; + rig.sdk.step_times = { -2.0f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + const PromptEvent* first = rig.last_prompt(); + REQUIRE(first != nullptr); + CHECK(first->continuous); + CHECK(first->mode == StepMode::Hold); + CHECK(first->duration_s == 0.0); + CHECK(first->estimated_s == 2.0); + + rig.confirm_current(); + CHECK(rig.sdk.count("start_step") == 1); + CHECK(rig.all().size() == 1); + REQUIRE(rig.all().size() == 1); + CHECK(rig.all()[0]->elapsed_s == 0.0); + + // No next prompt before the estimate has elapsed, and exactly at it the next prompt appears. + rig.clock.advance(1.9); + rig.engine->tick(); + CHECK(rig.all().size() == 1); + rig.clock.advance(0.1); + rig.engine->tick(); + REQUIRE(rig.all().size() == 2); + CHECK_FALSE(rig.last_prompt()->continuous); + CHECK(rig.all().back()->elapsed_s == 2.0); + + // A duplicate confirm during or after the window is acknowledged and never starts another SDK step. + rig.send(ConfirmCommand{ "att1", first->token }); + CHECK(rig.sdk.count("start_step") == 1); +} + +TEST_CASE("window pacing holds the last continuous step before finishing and exporting", + "[unit][engine][flow][continuous][window]") +{ + Rig rig(window_pacing()); + rig.sdk.steps = 1; + rig.sdk.step_times = { -3.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.confirm_current(); + + rig.clock.advance(2.9); + rig.engine->tick(); + CHECK_FALSE(rig.engine->finished()); + CHECK(rig.sdk.count("finish") == 0); + CHECK(rig.writer.files.empty()); + + rig.clock.advance(0.1); + rig.engine->tick(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); + CHECK(rig.writer.files.count("/stage/att1_left.mcal") == 1); +} + +TEST_CASE("a routine of all continuous steps completes under window pacing with bounded progress", + "[unit][engine][flow][continuous][window]") +{ + const std::vector real_times = { -2.0f, -2.0f, -10.0f, -5.0f, -3.0f, -3.0f, -3.0f, -3.0f }; + Rig rig(window_pacing()); + rig.sdk.steps = 8; + rig.sdk.step_times = real_times; + rig.start({ Side::Left, Side::Right }); + rig.run_to_end(); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); + CHECK(rig.sdk.count("start_step") == 16); + CHECK(rig.all().size() == 16); + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 16); + for (const StepProgressEvent* progress : rig.all()) + { + // Progress never exceeds the estimate of the prompt it belongs to. + for (const PromptEvent* prompt : prompts) + { + if (prompt->token == progress->token) + { + CHECK(progress->elapsed_s <= prompt->estimated_s); + } + } + } +} + +TEST_CASE("cancelling inside a window-paced continuous step ends cancelled and publishes no file", + "[unit][engine][flow][continuous][window]") +{ + Rig rig(window_pacing()); + rig.sdk.steps = 2; + rig.sdk.step_times = { -5.0f, -5.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.confirm_current(); + rig.clock.advance(1.0); + rig.engine->tick(); + + rig.engine->request_cancel(); + rig.engine->tick(); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Cancelled); + CHECK(rig.writer.files.empty()); +} + +TEST_CASE("a glove disappearing during a window-paced continuous step fails the attempt", + "[unit][engine][flow][continuous][window]") +{ + Rig rig(window_pacing()); + rig.sdk.steps = 1; + rig.sdk.step_times = { -5.0f }; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.confirm_current(); + + rig.sdk.landscape = {}; + rig.clock.advance(1.0); + rig.engine->tick(); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Failed); + CHECK(rig.writer.files.empty()); +} + +namespace +{ + +struct BadTime +{ + const char* name; + float value; +}; + +const BadTime kBadTimes[] = { + { "NaN", std::nanf("") }, + { "+inf", std::numeric_limits::infinity() }, + { "-inf", -std::numeric_limits::infinity() }, +}; + +// The attempt failed with step_failed for this glove and step, with nothing offered, exported or published. +void require_step_failed(const Rig& rig, Side side, uint32_t step) +{ + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK(result.files.empty()); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::StepFailed); + REQUIRE(result.error->side.has_value()); + CHECK(*result.error->side == side); + REQUIRE(result.error->step.has_value()); + CHECK(*result.error->step == step); + CHECK(rig.all().empty()); +} + +} // namespace + +TEST_CASE("an unusable step time at index 0 fails before any prompt", "[unit][engine][flow][step_time]") +{ + for (const BadTime& bad : kBadTimes) + { + CAPTURE(bad.name); + Rig rig; + rig.sdk.step_times = { bad.value, 0.0f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_step_failed(rig, Side::Left, 0); + CHECK(rig.all().empty()); + CHECK(rig.sdk.count("start_step") == 0); + CHECK(rig.sdk.count("stop") == 1); + CHECK(rig.index_of("sdk:stop:10") < rig.index_of("sdk:shutdown")); + CHECK(rig.all().empty()); + CHECK(rig.writer.files.empty()); + } +} + +TEST_CASE("an unusable step time at a later index fails before the first prompt", "[unit][engine][flow][step_time]") +{ + for (const BadTime& bad : kBadTimes) + { + CAPTURE(bad.name); + Rig rig; + rig.sdk.steps = 5; + rig.sdk.step_times = { 0.0f, 2.0f, 0.0f, bad.value, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_step_failed(rig, Side::Left, 3); + CHECK(rig.all().empty()); + CHECK(rig.sdk.count("start_step") == 0); + CHECK(rig.sdk.count("stop") == 1); + CHECK(rig.index_of("sdk:stop:10") < rig.index_of("sdk:shutdown")); + CHECK(rig.all().empty()); + CHECK(rig.writer.files.empty()); + } +} + +TEST_CASE("an unusable step time on the second glove fails only once the first glove has finished", + "[unit][engine][flow][step_time]") +{ + for (const BadTime& bad : kBadTimes) + { + CAPTURE(bad.name); + Rig rig; + rig.sdk.glove_step_times[20] = { 0.0f, bad.value, 0.0f }; + rig.start(); + rig.run_to_end(); + + // The right glove is only inspected when it is reached, after the left glove ran and exported. + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK(result.files.empty()); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::StepFailed); + REQUIRE(result.error->side.has_value()); + CHECK(*result.error->side == Side::Right); + REQUIRE(result.error->step.has_value()); + CHECK(*result.error->step == 1); + + const auto prompts = rig.all(); + REQUIRE(prompts.size() == 3); + for (const PromptEvent* prompt : prompts) + { + CHECK(prompt->side == Side::Left); + } + CHECK(rig.all().size() == 3); + CHECK(rig.sdk.count("start_step:20") == 0); + CHECK(rig.index_of("sdk:start:20") < rig.index_of("sdk:stop:20")); + CHECK(rig.index_of("sdk:stop:20") < rig.index_of("sdk:shutdown")); + CHECK(rig.sdk.count("stop:20") == 1); + } +} + +TEST_CASE("every prompt has a unique token", "[unit][engine][flow]") +{ + Rig rig; + rig.start(); + rig.run_to_end(); + + std::set tokens; + for (const PromptEvent* prompt : rig.all()) + { + tokens.insert(prompt->token); + } + CHECK(tokens.size() == rig.all().size()); +} + +TEST_CASE("each prompt needs its own confirmation, including the second glove's first", "[unit][engine][confirm]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + + for (int i = 0; i < 3; ++i) + { + confirm_and_advance(rig); + } + + // The left glove is done and the right glove's first prompt is waiting. + REQUIRE(rig.last_prompt() != nullptr); + CHECK(rig.last_prompt()->side == Side::Right); + CHECK(rig.last_prompt()->index == 0); + CHECK(rig.sdk.count("start_step") == 3); + CHECK(rig.all().size() == 3); + + // Time passing never starts a step by itself. + tick_for(rig, 40); + CHECK(rig.sdk.count("start_step") == 3); + CHECK(rig.all().size() == 3); + CHECK_FALSE(rig.engine->finished()); + + rig.confirm_current(); + CHECK(rig.sdk.count("start_step") == 4); + CHECK(has_call(rig, "sdk:start_step:20:0")); +} + +// --- confirmation handling --------------------------------------------------------------------------------- + +TEST_CASE("a repeated confirmation is acknowledged again without a second SDK step", "[unit][engine][confirm]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + const std::string token = rig.last_prompt()->token; + + rig.send(confirm(token)); + REQUIRE(rig.sdk.count("start_step") == 1); + const size_t prompts_after_first = rig.all().size(); + + // The prompt has already advanced; the duplicate still gets its acknowledgment and nothing else. + rig.send(confirm(token)); + rig.send(confirm(token)); + + size_t acks = 0; + for (const StepAcceptedEvent* accepted : rig.all()) + { + acks += accepted->token == token ? 1 : 0; + } + CHECK(acks == 3); + CHECK(rig.sdk.count("start_step") == 1); + CHECK(rig.all().size() == prompts_after_first); + CHECK(rig.rejections().empty()); +} + +TEST_CASE("a repeated confirmation during sampling does not restart the step", "[unit][engine][confirm]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + REQUIRE(rig.last_prompt()->mode == StepMode::Motion); + const std::string token = rig.last_prompt()->token; + + rig.confirm_current(); + REQUIRE(rig.sdk.count("start_step") == 2); + tick_for(rig, 2); + rig.send(confirm(token)); + + CHECK(rig.sdk.count("start_step") == 2); + CHECK_FALSE(rig.engine->finished()); + CHECK(rig.rejections().empty()); +} + +TEST_CASE("a token that was never offered is rejected as stale and changes nothing", "[unit][engine][confirm]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + const std::string offered = rig.last_prompt()->token; + const size_t events_before = rig.events.size(); + + rig.send(confirm("att1-999")); + // The next real token is also not valid until its prompt has been offered. + rig.send(confirm("att1-2")); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 2); + for (const RejectedEvent* rejection : rejections) + { + CHECK(rejection->reason == RejectReason::StaleToken); + CHECK(rejection->command == "confirm"); + CHECK(rejection->attempt == std::optional("att1")); + REQUIRE(rejection->token.has_value()); + } + CHECK(rejections[0]->token == std::optional("att1-999")); + CHECK(rejections[1]->token == std::optional("att1-2")); + CHECK(rig.events.size() == events_before + 2); + CHECK(rig.sdk.count("start_step") == 0); + CHECK_FALSE(rig.engine->finished()); + + // The real prompt is still waiting and still works. + CHECK(rig.last_prompt()->token == offered); + rig.send(confirm(offered)); + CHECK(rig.sdk.count("start_step") == 1); +} + +TEST_CASE("a confirmation for another attempt is rejected as an unknown attempt", "[unit][engine][confirm]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(confirm(rig.last_prompt()->token, "other")); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::UnknownAttempt); + CHECK(rejections[0]->attempt == std::optional("other")); + CHECK(rig.sdk.count("start_step") == 0); +} + +TEST_CASE("confirmations before any prompt are rejected", "[unit][engine][confirm]") +{ + SECTION("before start") + { + Rig rig; + rig.send(confirm("att1-1")); + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::UnknownAttempt); + CHECK(sdk_calls(rig).empty()); + CHECK_FALSE(rig.engine->finished()); + } + SECTION("during discovery") + { + Rig rig; + rig.sdk.landscape.clear(); + rig.start(); + rig.send(confirm("att1-1")); + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::Unexpected); + CHECK(rig.sdk.count("start_step") == 0); + CHECK_FALSE(rig.engine->finished()); + } +} + +TEST_CASE("a different token while a step is sampling is rejected and sampling continues", "[unit][engine][confirm]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + rig.confirm_current(); + REQUIRE(rig.sdk.count("start_step") == 2); + + rig.send(confirm("att1-3")); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::Unexpected); + CHECK(rig.sdk.count("start_step") == 2); + + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); +} + +TEST_CASE("a confirmation while the calibration is being saved is rejected", "[unit][engine][confirm]") +{ + Rig rig; + rig.sdk.on_export = [&rig]() { rig.send(confirm("late-token")); }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::Unexpected); + CHECK(rejections[0]->token == std::optional("late-token")); +} + +TEST_CASE("commands after the terminal result are ignored", "[unit][engine][terminal]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + + const size_t events_before = rig.events.size(); + const size_t calls_before = sdk_calls(rig).size(); + rig.send(confirm("att1-1")); + rig.send(CancelCommand{ "att1" }); + rig.send(StartCommand{ "att2", { Side::Left }, "/stage" }); + rig.engine->request_cancel(); + tick_for(rig, 5); + + CHECK(rig.events.size() == events_before); + CHECK(sdk_calls(rig).size() == calls_before); + CHECK(rig.engine->outcome() == Outcome::Completed); +} + +// --- start handling ---------------------------------------------------------------------------------------- + +TEST_CASE("a second start while an attempt is active is rejected as busy", "[unit][engine][start]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(StartCommand{ "att2", { Side::Right }, "/other" }); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::Busy); + CHECK(rejections[0]->command == "start"); + CHECK(rejections[0]->attempt == std::optional("att2")); + CHECK(rig.sdk.count("start:") == 1); + + // The running attempt is unaffected. + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).attempt == std::optional("att1")); + CHECK(the_result(rig).outcome == Outcome::Completed); +} + +TEST_CASE("a start with an unusable output directory is rejected before any SDK call", "[unit][engine][start]") +{ + Rig rig; + rig.writer.usable = false; + rig.start(); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::Invalid); + CHECK(rejections[0]->attempt == std::optional("att1")); + CHECK(sdk_calls(rig).empty()); + CHECK_FALSE(rig.engine->finished()); + CHECK(rig.all().empty()); + + // The engine is still idle, so a corrected start proceeds. + rig.writer.usable = true; + rig.start(); + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); +} + +TEST_CASE("ticking and cancelling before a start does nothing harmful", "[unit][engine][start]") +{ + Rig rig; + rig.engine->tick(); + rig.send(CancelCommand{ "att1" }); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::UnknownAttempt); + CHECK(rejections[0]->command == "cancel"); + CHECK(sdk_calls(rig).empty()); + CHECK_FALSE(rig.engine->finished()); +} + +TEST_CASE("a cancel requested before the start ends as cancelled without touching the SDK", "[unit][engine][start]") +{ + Rig rig; + rig.engine->request_cancel(); + rig.start(); + + REQUIRE(rig.engine->finished()); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Cancelled); + CHECK_FALSE(result.attempt.has_value()); + CHECK(result.files.empty()); + CHECK(sdk_calls(rig).empty()); + CHECK(rig.events.size() == 1); +} + +// --- discovery --------------------------------------------------------------------------------------------- + +TEST_CASE("discovery waits through connecting attempts", "[unit][engine][discovery]") +{ + Rig rig; + rig.sdk.connecting_attempts = 2; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + REQUIRE(rig.last_prompt() != nullptr); + CHECK(rig.sdk.count("connect") == 3); + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); +} + +TEST_CASE("gloves that appear late are picked up and reported", "[unit][engine][discovery]") +{ + Rig rig; + rig.sdk.landscape.clear(); + rig.start(); + tick_for(rig, 6, 0.5); + CHECK(rig.last_prompt() == nullptr); + CHECK_FALSE(rig.engine->finished()); + + rig.sdk.landscape = { { Side::Right, 20 } }; + tick_for(rig, 4, 0.5); + CHECK(rig.last_prompt() == nullptr); + + rig.sdk.landscape = { { Side::Left, 10 }, { Side::Right, 20 } }; + rig.run_to_prompt(); + REQUIRE(rig.last_prompt() != nullptr); + CHECK(rig.last_prompt()->side == Side::Left); + + // Discovery progress is reported as the visible set changes, never as a repeated identical status. + std::vector glove_counts; + for (const StatusEvent* status : rig.all()) + { + if (status->phase == Phase::Discovering) + { + glove_counts.push_back(status->gloves.size()); + } + } + REQUIRE(glove_counts.size() >= 2); + for (size_t i = 1; i < glove_counts.size(); ++i) + { + CHECK(glove_counts[i] != glove_counts[i - 1]); + } + CHECK(glove_counts.back() == 2); +} + +TEST_CASE("discovery times out with no_gloves and shuts the SDK down", "[unit][engine][discovery]") +{ + Rig rig; + rig.sdk.landscape.clear(); + rig.start(); + tick_until_finished(rig); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::NoGloves); + CHECK_THAT(result.error->message, ContainsSubstring("left, right")); + CHECK(result.files.empty()); + CHECK(rig.clock.now - 100.0 >= rig.config.discovery_timeout_s); + CHECK(rig.sdk.count("start:") == 0); + CHECK(rig.sdk.count("stop") == 0); +} + +TEST_CASE("the timeout names only the gloves that were not found", "[unit][engine][discovery]") +{ + Rig rig; + rig.sdk.landscape = { { Side::Left, 10 } }; + rig.start({ Side::Left, Side::Right }); + tick_until_finished(rig); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::NoGloves); + CHECK_THAT(result.error->message, ContainsSubstring("right")); + CHECK_THAT(result.error->message, !ContainsSubstring("left")); + // Nothing is calibrated when only some of the requested gloves are present. + CHECK(rig.sdk.count("start:") == 0); +} + +TEST_CASE("a failed connection ends the attempt with the host's message", "[unit][engine][discovery]") +{ + Rig rig; + rig.sdk.fail_call = "connect"; + rig.start(); + tick_until_finished(rig); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::SdkInitFailed); + CHECK_THAT(result.error->message, ContainsSubstring("injected")); +} + +// --- progress ---------------------------------------------------------------------------------------------- + +TEST_CASE("sampling progress is monotonic and never exceeds the step duration", "[unit][engine][progress]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + + const PromptEvent* motion = rig.last_prompt(); + REQUIRE(motion->mode == StepMode::Motion); + const std::string token = motion->token; + const double duration = motion->duration_s; + const size_t prompts_before = rig.all().size(); + rig.confirm_current(); + + // Mid-window: progress is flowing and no further prompt has been offered. + tick_for(rig, 5); + CHECK(rig.all().size() == prompts_before); + + tick_for(rig, 10); + + std::vector elapsed; + for (const StepProgressEvent* progress : rig.all()) + { + if (progress->token == token) + { + elapsed.push_back(progress->elapsed_s); + } + } + REQUIRE(elapsed.size() >= 3); + CHECK(elapsed.front() == 0.0); + CHECK(elapsed.back() == duration); + for (size_t i = 0; i < elapsed.size(); ++i) + { + CHECK(elapsed[i] >= 0.0); + CHECK(elapsed[i] <= duration); + if (i > 0) + { + CHECK(elapsed[i] >= elapsed[i - 1]); + } + } + // The next prompt appears only once the window is over. + CHECK(rig.all().size() == prompts_before + 1); + CHECK(rig.last_prompt()->index == 2); +} + +TEST_CASE("a long gap between ticks still reports progress capped at the duration", "[unit][engine][progress]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + const std::string token = rig.last_prompt()->token; + rig.confirm_current(); + + rig.clock.advance(50.0); + rig.engine->tick(); + + const auto progress = rig.all(); + REQUIRE_FALSE(progress.empty()); + CHECK(progress.back()->token == token); + CHECK(progress.back()->elapsed_s == 2.0); + CHECK(rig.last_prompt()->index == 2); +} + +TEST_CASE("progress events appear only for motion steps", "[unit][engine][progress]") +{ + Rig rig; + rig.sdk.step_times = { 0.0f, 0.0f, 0.0f }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + CHECK(rig.all().empty()); +} + +// --- glove presence ---------------------------------------------------------------------------------------- + +TEST_CASE("the other glove disappearing while a prompt is waiting fails the attempt", "[unit][engine][gloves]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + REQUIRE(rig.last_prompt()->side == Side::Left); + + rig.sdk.landscape = { { Side::Left, 10 } }; + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::GloveMissing); + CHECK(result.error->side == std::optional(Side::Right)); + CHECK(result.files.empty()); + // The glove that was being calibrated is told to stop before the SDK goes down. + CHECK(has_call(rig, "sdk:stop:10")); + CHECK(rig.index_of("sdk:stop:10") < rig.index_of("sdk:shutdown")); +} + +TEST_CASE("the calibrated glove disappearing during sampling fails with the step index", "[unit][engine][gloves]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + rig.confirm_current(); + + rig.sdk.landscape = { { Side::Right, 20 } }; + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::GloveMissing); + CHECK(result.error->side == std::optional(Side::Left)); + CHECK(result.error->step == std::optional(1)); + CHECK(has_call(rig, "sdk:stop:10")); +} + +TEST_CASE("a changed glove id is reported as a replaced glove", "[unit][engine][gloves]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.sdk.landscape = { { Side::Left, 11 }, { Side::Right, 20 } }; + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::GloveReplaced); + CHECK(result.error->side == std::optional(Side::Left)); + CHECK_THAT(result.error->message, ContainsSubstring("10")); + CHECK_THAT(result.error->message, ContainsSubstring("11")); +} + +TEST_CASE("a glove replaced during sampling fails the attempt", "[unit][engine][gloves]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + rig.confirm_current(); + + rig.sdk.landscape = { { Side::Left, 99 }, { Side::Right, 20 } }; + tick_for(rig, 1); + + require_clean_terminal(rig); + REQUIRE(the_result(rig).error.has_value()); + CHECK(the_result(rig).error->code == ErrorCode::GloveReplaced); + CHECK(the_result(rig).error->step == std::optional(1)); +} + +TEST_CASE("a glove that vanishes before its confirmation is processed never starts the step", "[unit][engine][gloves]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + const std::string token = rig.last_prompt()->token; + + rig.sdk.landscape.clear(); + rig.send(confirm(token)); + + require_clean_terminal(rig); + CHECK(rig.sdk.count("start_step") == 0); + REQUIRE(the_result(rig).error.has_value()); + CHECK(the_result(rig).error->code == ErrorCode::GloveMissing); +} + +TEST_CASE("the second glove vanishing mid-attempt keeps the first export out of the result", "[unit][engine][gloves]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + for (int i = 0; i < 3; ++i) + { + confirm_and_advance(rig); + } + REQUIRE(rig.all().size() == 1); + + rig.sdk.landscape = { { Side::Left, 10 } }; + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK(result.files.empty()); + CHECK(serialize_event(result).find("files") == std::string::npos); +} + +// --- SDK failure injection --------------------------------------------------------------------------------- + +namespace +{ + +struct Injection +{ + const char* call; + ErrorCode code; + bool names_glove; + int stops; +}; + +} // namespace + +TEST_CASE("an SDK failure at each call ends the attempt as failed", "[unit][engine][failure]") +{ + // The engine receives the SDK return code and operation result already folded into one status, so a + // failing return code and a false result boolean look identical here. + const std::vector injections = { + { "initialize", ErrorCode::SdkInitFailed, false, 0 }, + { "connect", ErrorCode::SdkInitFailed, false, 0 }, + { "step_count", ErrorCode::SdkError, true, 0 }, + { "step_data", ErrorCode::SdkError, true, 1 }, + { "start", ErrorCode::SdkError, true, 0 }, + { "start_step", ErrorCode::StepFailed, true, 1 }, + { "finish", ErrorCode::SdkError, true, 1 }, + { "export", ErrorCode::ExportFailed, true, 0 }, + }; + for (const Injection& injection : injections) + { + DYNAMIC_SECTION("failure at " << injection.call) + { + Rig rig; + rig.sdk.fail_call = injection.call; + rig.start(); + rig.run_to_end(); + tick_until_finished(rig); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK(result.files.empty()); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == injection.code); + CHECK(result.error->side.has_value() == injection.names_glove); + CHECK_THAT(result.error->message, ContainsSubstring("injected")); + // A calibration that was running is stopped; one that was never started is not. + CHECK(rig.sdk.count("stop") == injection.stops); + CHECK(rig.engine->outcome() == Outcome::Failed); + // Nothing is published for a failed attempt, so no exported file is ever advertised. + CHECK(serialize_event(result).find("\"files\"") == std::string::npos); + } + } +} + +TEST_CASE("a failure on the second step names that step", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.fail_call = "step_data"; + rig.sdk.fail_nth = 2; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + REQUIRE(the_result(rig).error.has_value()); + CHECK(the_result(rig).error->code == ErrorCode::SdkError); + CHECK(the_result(rig).error->step == std::optional(1)); + CHECK(rig.sdk.count("stop") == 1); +} + +TEST_CASE("a start_step failure never reports the step as accepted", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.fail_call = "start_step"; + rig.start({ Side::Left }); + rig.run_to_prompt(); + const std::string token = rig.last_prompt()->token; + rig.send(confirm(token)); + + require_clean_terminal(rig); + CHECK(rig.all().empty()); + CHECK(the_result(rig).error->code == ErrorCode::StepFailed); + + // A retry of the same token after the failure gets nothing. + const size_t events = rig.events.size(); + rig.send(confirm(token)); + CHECK(rig.events.size() == events); +} + +TEST_CASE("a failed export of the second glove fails the whole attempt", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.fail_call = "export"; + rig.sdk.fail_nth = 2; + rig.start(); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::ExportFailed); + CHECK(result.error->side == std::optional(Side::Right)); + // The first glove's file was written and announced, but a failed attempt lists no files. + CHECK(rig.all().size() == 1); + CHECK(result.files.empty()); + CHECK(rig.sdk.count("stop") == 0); +} + +TEST_CASE("a calibration with no steps fails before starting the SDK routine", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.steps = 0; + rig.start(); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::SdkError); + CHECK(result.error->side == std::optional(Side::Left)); + CHECK_THAT(result.error->message, ContainsSubstring("no calibration steps")); + CHECK(rig.sdk.count("start:") == 0); + CHECK(rig.sdk.count("stop") == 0); + CHECK(rig.all().empty()); +} + +TEST_CASE("an empty export is rejected and nothing is written", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.export_bytes.clear(); + rig.start(); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::ExportFailed); + CHECK_THAT(result.error->message, ContainsSubstring("empty")); + CHECK(rig.writer.files.empty()); + CHECK(rig.all().empty()); + CHECK(result.files.empty()); +} + +TEST_CASE("an export whose size and bytes disagree is rejected and nothing is written", "[unit][engine][failure]") +{ + // The size/bytes comparison happens in the SDK binding, which reports a mismatch as a failed export even + // though the byte buffer was partly filled. The engine must not write that buffer. + Rig rig; + rig.sdk.fail_call = "export"; + rig.sdk.export_bytes = { 1, 2, 3 }; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + CHECK(the_result(rig).error->code == ErrorCode::ExportFailed); + CHECK(rig.writer.files.empty()); + CHECK(rig.all().empty()); +} + +TEST_CASE("a write failure fails the attempt and nothing is announced", "[unit][engine][failure]") +{ + Rig rig; + rig.writer.fail_with = "disk full"; + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::ExportFailed); + CHECK(result.error->side == std::optional(Side::Left)); + CHECK_THAT(result.error->message, ContainsSubstring("disk full")); + CHECK(rig.all().empty()); + CHECK(result.files.empty()); + // The glove's calibration had already finished, so there is nothing to stop. + CHECK(rig.sdk.count("stop") == 0); +} + +TEST_CASE("a write failure on the second glove leaves no files in the result", "[unit][engine][failure]") +{ + Rig rig; + rig.sdk.on_export = [&rig]() + { + if (rig.sdk.count("export") >= 1) + { + rig.writer.fail_with = "read-only filesystem"; + } + }; + rig.start(); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->side == std::optional(Side::Right)); + CHECK(rig.all().size() == 1); + CHECK(result.files.empty()); +} + +// --- cancellation ------------------------------------------------------------------------------------------ + +TEST_CASE("cancelling during discovery ends cancelled with the SDK shut down", "[unit][engine][cancel]") +{ + SECTION("through the thread-safe request") + { + Rig rig; + rig.sdk.landscape.clear(); + rig.start(); + tick_for(rig, 2, 0.5); + REQUIRE_FALSE(rig.engine->finished()); + + rig.engine->request_cancel(); + tick_for(rig, 1, 0.5); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Cancelled); + CHECK(the_result(rig).files.empty()); + CHECK_FALSE(the_result(rig).error.has_value()); + CHECK(rig.sdk.count("stop") == 0); + CHECK(rig.sdk.count("start:") == 0); + } + SECTION("through a cancel command") + { + Rig rig; + rig.sdk.landscape.clear(); + rig.start(); + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Cancelled); + CHECK(rig.sdk.count("stop") == 0); + } +} + +TEST_CASE("cancelling while a prompt awaits confirmation stops the glove calibration", "[unit][engine][cancel]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + const std::string token = rig.last_prompt()->token; + + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Cancelled); + CHECK(result.files.empty()); + CHECK_FALSE(result.error.has_value()); + CHECK(rig.sdk.count("stop") == 1); + CHECK(has_call(rig, "sdk:stop:10")); + CHECK(rig.index_of("sdk:stop:10") < rig.index_of("sdk:shutdown")); + CHECK(rig.engine->outcome() == Outcome::Cancelled); + + // The prompt that was waiting can no longer start a step. + const size_t events = rig.events.size(); + rig.send(confirm(token)); + CHECK(rig.sdk.count("start_step") == 0); + CHECK(rig.events.size() == events); +} + +TEST_CASE("cancelling during sampling stops the step and offers nothing more", "[unit][engine][cancel]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + confirm_and_advance(rig); + rig.confirm_current(); + tick_for(rig, 2); + const size_t prompts = rig.all().size(); + const size_t progress = rig.all().size(); + + rig.engine->request_cancel(); + tick_for(rig, 1); + + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Cancelled); + CHECK(rig.sdk.count("stop") == 1); + CHECK(rig.all().size() == prompts); + CHECK(rig.all().size() == progress); + CHECK(rig.sdk.count("export") == 0); + + // Further time has no effect once finished. + tick_for(rig, 10); + CHECK(rig.all().size() == progress); +} + +TEST_CASE("cancelling while saving writes nothing and reports cancelled", "[unit][engine][cancel]") +{ + Rig rig; + rig.sdk.on_export = [&rig]() { rig.engine->request_cancel(); }; + rig.start(); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Cancelled); + CHECK(result.files.empty()); + CHECK(rig.writer.files.empty()); + CHECK(rig.all().empty()); + // The glove's calibration had already finished, so only shutdown is needed. + CHECK(rig.sdk.count("stop") == 0); + CHECK(rig.sdk.count("export") == 1); +} + +TEST_CASE("cancelling after the first glove was exported leaves no files in the result", "[unit][engine][cancel]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + for (int i = 0; i < 3; ++i) + { + confirm_and_advance(rig); + } + REQUIRE(rig.all().size() == 1); + REQUIRE(rig.last_prompt()->side == Side::Right); + + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Cancelled); + CHECK(result.files.empty()); + CHECK(has_call(rig, "sdk:stop:20")); + CHECK_FALSE(has_call(rig, "sdk:stop:10")); + CHECK(serialize_event(result).find("files") == std::string::npos); +} + +TEST_CASE("a cancel for another attempt is rejected and the attempt continues", "[unit][engine][cancel]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(CancelCommand{ "other" }); + + const auto rejections = rig.rejections(); + REQUIRE(rejections.size() == 1); + CHECK(rejections[0]->reason == RejectReason::UnknownAttempt); + CHECK(rejections[0]->command == "cancel"); + CHECK_FALSE(rig.engine->finished()); + + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); +} + +TEST_CASE("cancelling twice yields one terminal result", "[unit][engine][cancel]") +{ + Rig rig; + rig.start(); + rig.run_to_prompt(); + + rig.send(CancelCommand{ "att1" }); + rig.send(CancelCommand{ "att1" }); + rig.engine->request_cancel(); + tick_for(rig, 3); + + require_clean_terminal(rig); + CHECK(rig.sdk.count("stop") == 1); + CHECK(rig.sdk.count("shutdown") == 1); +} + +TEST_CASE("a cancel after completion does not change the outcome", "[unit][engine][cancel]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + + rig.send(CancelCommand{ "att1" }); + rig.engine->request_cancel(); + rig.engine->tick(); + + CHECK(rig.engine->outcome() == Outcome::Completed); + CHECK(the_result(rig).outcome == Outcome::Completed); + CHECK(rig.all().size() == 1); +} + +TEST_CASE("a calibration that cannot be stopped is not reported as cleanly cancelled", "[unit][engine][cancel]") +{ + Rig rig; + rig.sdk.fail_call = "stop"; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::SdkError); + CHECK_THAT(result.error->message, ContainsSubstring("cannot stop")); + CHECK(result.files.empty()); + // The SDK is still shut down even though the stop failed. + CHECK(rig.sdk.count("shutdown") == 1); +} + +TEST_CASE("a failed stop during a failure keeps the original error", "[unit][engine][cancel]") +{ + Rig rig; + rig.sdk.fail_call = "stop"; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.sdk.landscape.clear(); + tick_for(rig, 1); + + require_clean_terminal(rig); + REQUIRE(the_result(rig).error.has_value()); + CHECK(the_result(rig).error->code == ErrorCode::GloveMissing); + CHECK(the_result(rig).outcome == Outcome::Failed); +} + +// --- terminal ordering ------------------------------------------------------------------------------------- + +TEST_CASE("every terminal path announces stopping, shuts down, then sends the result", "[unit][engine][terminal]") +{ + SECTION("completed") + { + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(rig.index_of("event:exported") < rig.index_of("sdk:shutdown")); + } + SECTION("failed") + { + Rig rig; + rig.sdk.fail_call = "start"; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + } + SECTION("cancelled") + { + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.send(CancelCommand{ "att1" }); + require_clean_terminal(rig); + } +} + +TEST_CASE("the last status before the result is stopping", "[unit][engine][terminal]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const auto statuses = rig.all(); + REQUIRE_FALSE(statuses.empty()); + CHECK(statuses.back()->phase == Phase::Stopping); + CHECK(statuses.back()->attempt == "att1"); +} + +TEST_CASE("status reports each phase of a completed attempt in order", "[unit][engine][terminal]") +{ + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + + std::vector phases; + for (const StatusEvent* status : rig.all()) + { + if (phases.empty() || phases.back() != status->phase) + { + phases.push_back(status->phase); + } + } + CHECK(phases.front() == Phase::Discovering); + CHECK(phases.back() == Phase::Stopping); + CHECK(std::find(phases.begin(), phases.end(), Phase::AwaitingStep) != phases.end()); + CHECK(std::find(phases.begin(), phases.end(), Phase::CapturingStep) != phases.end()); + CHECK(std::find(phases.begin(), phases.end(), Phase::Saving) != phases.end()); +} + +TEST_CASE("a completed result lists exactly the exported files with matching hashes", "[unit][engine][terminal]") +{ + Rig rig; + rig.start(); + rig.run_to_end(); + require_clean_terminal(rig); + + const ResultEvent& result = the_result(rig); + const auto exported = rig.all(); + REQUIRE(result.files.size() == exported.size()); + for (size_t i = 0; i < exported.size(); ++i) + { + CHECK(result.files[i].side == exported[i]->side); + CHECK(result.files[i].file == exported[i]->file); + CHECK(result.files[i].bytes == exported[i]->bytes); + CHECK(result.files[i].sha256 == exported[i]->sha256); + CHECK(rig.writer.files.count(std::string("/stage/") + result.files[i].file) == 1); + } +} + +// --- cleanup evidence -------------------------------------------------------------------------------------- + +TEST_CASE("clean runs report the gloves released", "[unit][engine][released]") +{ + SECTION("a completed calibration") + { + Rig rig; + rig.start({ Side::Left }); + rig.run_to_end(); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Completed); + CHECK(the_result(rig).released); + } + SECTION("a cancel at the first prompt") + { + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.send(CancelCommand{ "att1" }); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Cancelled); + CHECK(the_result(rig).released); + } + SECTION("a failure whose cleanup succeeded") + { + Rig rig; + rig.start({ Side::Left }); + rig.run_to_prompt(); + rig.sdk.landscape.clear(); + tick_for(rig, 1); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Failed); + CHECK(the_result(rig).released); + } + SECTION("a discovery timeout") + { + Rig rig; + rig.sdk.landscape.clear(); + rig.start({ Side::Left }); + tick_until_finished(rig); + require_clean_terminal(rig); + CHECK(the_result(rig).outcome == Outcome::Failed); + CHECK(the_result(rig).released); + } +} + +TEST_CASE("a cancel before the start never touched the SDK and so reports it released", "[unit][engine][released]") +{ + Rig rig; + rig.engine->request_cancel(); + rig.start(); + + REQUIRE(rig.engine->finished()); + CHECK(sdk_calls(rig).empty()); + CHECK(the_result(rig).released); +} + +TEST_CASE("a failed SDK shutdown after a would-be completed run fails the attempt and reports not released", + "[unit][engine][released]") +{ + Rig rig; + rig.sdk.shutdown_result = SdkStatus::failure("injected shutdown"); + rig.start({ Side::Left }); + rig.run_to_end(); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK(result.files.empty()); + CHECK_FALSE(result.released); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::SdkError); + CHECK_THAT(result.error->message, ContainsSubstring("cannot shut down the SDK")); + CHECK_THAT(result.error->message, ContainsSubstring("injected shutdown")); + CHECK_THAT(serialize_event(result), ContainsSubstring("\"released\":false")); + CHECK(rig.engine->outcome() == Outcome::Failed); +} + +TEST_CASE("a failed SDK shutdown after a cancel downgrades it to failed and reports not released", + "[unit][engine][released]") +{ + Rig rig; + rig.sdk.shutdown_result = SdkStatus::failure("injected shutdown"); + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK_FALSE(result.released); + REQUIRE(result.error.has_value()); + CHECK_THAT(result.error->message, ContainsSubstring("cannot shut down the SDK")); +} + +TEST_CASE("a failed SDK shutdown during an earlier failure keeps the original error and reports not released", + "[unit][engine][released]") +{ + Rig rig; + rig.sdk.shutdown_result = SdkStatus::failure("injected shutdown"); + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.sdk.landscape.clear(); + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK_FALSE(result.released); + REQUIRE(result.error.has_value()); + // The attempt's own failure stays the reported cause; the release failure is carried by the flag. + CHECK(result.error->code == ErrorCode::GloveMissing); +} + +TEST_CASE("a failed calibration stop during a cancel reports not released", "[unit][engine][released]") +{ + Rig rig; + rig.sdk.fail_call = "stop"; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.send(CancelCommand{ "att1" }); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK_FALSE(result.released); + REQUIRE(result.error.has_value()); + CHECK_THAT(result.error->message, ContainsSubstring("cannot stop")); +} + +TEST_CASE("a failed calibration stop during an earlier failure keeps the original error and reports not released", + "[unit][engine][released]") +{ + Rig rig; + rig.sdk.fail_call = "stop"; + rig.start({ Side::Left }); + rig.run_to_prompt(); + + rig.sdk.landscape.clear(); + tick_for(rig, 1); + + require_clean_terminal(rig); + const ResultEvent& result = the_result(rig); + CHECK(result.outcome == Outcome::Failed); + CHECK_FALSE(result.released); + REQUIRE(result.error.has_value()); + CHECK(result.error->code == ErrorCode::GloveMissing); +} diff --git a/tests/cpp/plugins/manus_calibration/test_export_writer.cpp b/tests/cpp/plugins/manus_calibration/test_export_writer.cpp new file mode 100644 index 0000000000..4a0cc39a02 --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/test_export_writer.cpp @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +namespace fs = std::filesystem; +using Catch::Matchers::ContainsSubstring; +using plugins::manus::calibration::PosixExportWriter; + +// A unique directory removed with its contents when the test ends. +class TempDir +{ +public: + TempDir() + { + std::string pattern = (fs::temp_directory_path() / "manus_cal_writer_XXXXXX").string(); + REQUIRE(::mkdtemp(pattern.data()) != nullptr); + m_path = pattern; + } + + ~TempDir() + { + // Permissions may have been dropped by a test; restore them so the cleanup can descend. + std::error_code ignored; + fs::permissions(m_path, fs::perms::owner_all, fs::perm_options::add, ignored); + fs::remove_all(m_path, ignored); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + std::string str() const + { + return m_path.string(); + } + + fs::path operator/(const std::string& name) const + { + return m_path / name; + } + +private: + fs::path m_path; +}; + +std::vector read_file(const fs::path& path) +{ + std::ifstream in(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +std::vector names_in(const fs::path& dir) +{ + std::vector names; + for (const auto& entry : fs::directory_iterator(dir)) + { + names.push_back(entry.path().filename().string()); + } + return names; +} + +} // namespace + +TEST_CASE("directory_usable accepts only an existing writable directory", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + + CHECK(writer.directory_usable(dir.str())); + CHECK_FALSE(writer.directory_usable((dir / "missing").string())); + + std::ofstream(dir / "plain_file") << "x"; + CHECK_FALSE(writer.directory_usable((dir / "plain_file").string())); +} + +TEST_CASE("directory_usable rejects a directory the worker cannot write to", "[unit][export_writer]") +{ + if (::geteuid() == 0) + { + SKIP("root ignores directory permissions"); + } + TempDir dir; + fs::create_directory(dir / "locked"); + fs::permissions(dir / "locked", fs::perms::owner_read | fs::perms::owner_exec); + + CHECK_FALSE(PosixExportWriter{}.directory_usable((dir / "locked").string())); +} + +TEST_CASE("write publishes the exact bytes under the final name and leaves no .part file", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + const std::vector payload = { 0, 1, 2, 0xFF, 0x00, 0x7F }; + + REQUIRE_FALSE(writer.write(dir.str(), "att_left.mcal", payload).has_value()); + + CHECK(read_file(dir / "att_left.mcal") == payload); + CHECK(names_in(dir.str()) == std::vector{ "att_left.mcal" }); + + struct stat info + { + }; + REQUIRE(::stat((dir / "att_left.mcal").c_str(), &info) == 0); + // Calibration data is private to the user that ran the worker. + CHECK((info.st_mode & 0777) == 0600); +} + +TEST_CASE("write handles an empty payload and a large payload", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + + REQUIRE_FALSE(writer.write(dir.str(), "empty.mcal", {}).has_value()); + CHECK(fs::file_size(dir / "empty.mcal") == 0); + + std::vector large(3 * 1024 * 1024); + for (size_t i = 0; i < large.size(); ++i) + { + large[i] = static_cast(i * 31); + } + REQUIRE_FALSE(writer.write(dir.str(), "large.mcal", large).has_value()); + CHECK(read_file(dir / "large.mcal") == large); +} + +TEST_CASE("write refuses a basename that could leave the output directory", "[unit][export_writer]") +{ + TempDir dir; + fs::create_directory(dir / "inner"); + PosixExportWriter writer; + + CHECK(writer.write((dir / "inner").string(), "", { 1 }).has_value()); + CHECK(writer.write((dir / "inner").string(), "../escape.mcal", { 1 }).has_value()); + CHECK(writer.write((dir / "inner").string(), "sub/file.mcal", { 1 }).has_value()); + CHECK(writer.write((dir / "inner").string(), "/abs.mcal", { 1 }).has_value()); + + CHECK(names_in(dir.str()) == std::vector{ "inner" }); + CHECK(names_in(dir / "inner").empty()); +} + +TEST_CASE("write fails cleanly when the directory does not exist", "[unit][export_writer]") +{ + TempDir dir; + const auto error = PosixExportWriter{}.write((dir / "missing").string(), "a.mcal", { 1 }); + + REQUIRE(error.has_value()); + CHECK_THAT(*error, ContainsSubstring("a.mcal.part")); +} + +TEST_CASE("write does not touch a stale .part file or write through a planted symlink", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + + std::ofstream(dir / "stale.mcal.part") << "old"; + CHECK(writer.write(dir.str(), "stale.mcal", { 9 }).has_value()); + CHECK(read_file(dir / "stale.mcal.part") == std::vector{ 'o', 'l', 'd' }); + CHECK_FALSE(fs::exists(dir / "stale.mcal")); + + std::ofstream(dir / "target") << "victim"; + fs::create_symlink(dir / "target", dir / "link.mcal.part"); + CHECK(writer.write(dir.str(), "link.mcal", { 9 }).has_value()); + CHECK(read_file(dir / "target") == std::vector{ 'v', 'i', 'c', 't', 'i', 'm' }); +} + +TEST_CASE("write never overwrites an existing final file", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + const std::vector original = { 1, 2, 3 }; + + REQUIRE_FALSE(writer.write(dir.str(), "keep.mcal", original).has_value()); + const auto second = writer.write(dir.str(), "keep.mcal", { 9, 9, 9, 9 }); + + CHECK(second.has_value()); + CHECK(read_file(dir / "keep.mcal") == original); + CHECK(names_in(dir.str()) == std::vector{ "keep.mcal" }); +} + +TEST_CASE("write reports a publish failure and removes the .part file", "[unit][export_writer]") +{ + TempDir dir; + // Anything already on the final name makes the publish step fail. + fs::create_directory(dir / "taken.mcal"); + std::ofstream(dir / "taken.mcal" / "inside") << "x"; + + const auto error = PosixExportWriter{}.write(dir.str(), "taken.mcal", { 1, 2, 3 }); + + REQUIRE(error.has_value()); + CHECK_THAT(*error, ContainsSubstring("publish")); + CHECK_FALSE(fs::exists(dir / "taken.mcal.part")); + CHECK(fs::is_directory(dir / "taken.mcal")); +} + +TEST_CASE("write reports a write failure and removes the .part file", "[unit][export_writer]") +{ + TempDir dir; + PosixExportWriter writer; + + // A file-size limit turns the write into EFBIG without needing a full disk. + struct rlimit original + { + }; + REQUIRE(::getrlimit(RLIMIT_FSIZE, &original) == 0); + void (*previous)(int) = std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limited = original; + limited.rlim_cur = 4096; + if (::setrlimit(RLIMIT_FSIZE, &limited) != 0) + { + std::signal(SIGXFSZ, previous); + SKIP("cannot lower RLIMIT_FSIZE here"); + } + + const auto error = writer.write(dir.str(), "big.mcal", std::vector(1024 * 1024, 0xAB)); + + ::setrlimit(RLIMIT_FSIZE, &original); + std::signal(SIGXFSZ, previous); + + REQUIRE(error.has_value()); + CHECK_THAT(*error, ContainsSubstring("cannot write")); + CHECK(names_in(dir.str()).empty()); +} diff --git a/tests/cpp/plugins/manus_calibration/test_protocol.cpp b/tests/cpp/plugins/manus_calibration/test_protocol.cpp new file mode 100644 index 0000000000..ea50b52310 --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/test_protocol.cpp @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include +#include +#include +#include + +namespace +{ + +using namespace plugins::manus::calibration; +using Catch::Matchers::ContainsSubstring; + +const StartCommand* as_start(const ParsedCommand& parsed) +{ + return parsed.command ? std::get_if(&*parsed.command) : nullptr; +} + +const ConfirmCommand* as_confirm(const ParsedCommand& parsed) +{ + return parsed.command ? std::get_if(&*parsed.command) : nullptr; +} + +const CancelCommand* as_cancel(const ParsedCommand& parsed) +{ + return parsed.command ? std::get_if(&*parsed.command) : nullptr; +} + +// Every outcome is exactly one of a command or a rejection. +void require_rejected(const std::string& line, const std::string& expected_command = "unknown") +{ + INFO("line: " << line.substr(0, 200)); + const ParsedCommand parsed = parse_command(line); + REQUIRE_FALSE(parsed.command.has_value()); + REQUIRE(parsed.rejection.has_value()); + CHECK(parsed.rejection->reason == RejectReason::Invalid); + CHECK(parsed.rejection->command == expected_command); + CHECK_FALSE(parsed.rejection->message.empty()); +} + +std::string start_line(const std::string& attempt = "att1", + const std::string& sides = "[\"left\",\"right\"]", + const std::string& out_dir = "/stage") +{ + return "{\"v\":1,\"type\":\"start\",\"attempt\":\"" + attempt + "\",\"sides\":" + sides + ",\"out_dir\":\"" + + out_dir + "\"}"; +} + +std::string confirm_line(const std::string& token = "att1-1") +{ + return "{\"v\":1,\"type\":\"confirm\",\"attempt\":\"att1\",\"token\":\"" + token + "\"}"; +} + +} // namespace + +TEST_CASE("valid commands parse into typed commands", "[unit][protocol][parse]") +{ + const ParsedCommand start = parse_command(start_line()); + REQUIRE(as_start(start)); + CHECK(start.command.has_value()); + CHECK_FALSE(start.rejection.has_value()); + CHECK(as_start(start)->attempt == "att1"); + CHECK(as_start(start)->sides == std::vector{ Side::Left, Side::Right }); + CHECK(as_start(start)->out_dir == "/stage"); + + const ParsedCommand confirm = parse_command(confirm_line("att1-7")); + REQUIRE(as_confirm(confirm)); + CHECK(as_confirm(confirm)->attempt == "att1"); + CHECK(as_confirm(confirm)->token == "att1-7"); + + const ParsedCommand cancel = parse_command("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"att1\"}"); + REQUIRE(as_cancel(cancel)); + CHECK(as_cancel(cancel)->attempt == "att1"); +} + +TEST_CASE("start sides are normalized to left then right", "[unit][protocol][parse]") +{ + CHECK(as_start(parse_command(start_line("a", "[\"right\",\"left\"]")))->sides == + std::vector{ Side::Left, Side::Right }); + CHECK(as_start(parse_command(start_line("a", "[\"right\"]")))->sides == std::vector{ Side::Right }); + CHECK(as_start(parse_command(start_line("a", "[\"left\"]")))->sides == std::vector{ Side::Left }); +} + +TEST_CASE("whitespace and a trailing carriage return around the object are tolerated", "[unit][protocol][parse]") +{ + CHECK(as_cancel(parse_command(" \t{ \"v\" : 1 , \"type\" : \"cancel\" , \"attempt\" : \"a\" }\r\n"))); + CHECK(as_cancel(parse_command("{\"attempt\":\"a\",\"type\":\"cancel\",\"v\":1}"))); +} + +TEST_CASE("start rejects bad sides", "[unit][protocol][parse]") +{ + require_rejected(start_line("a", "[]"), "start"); + require_rejected(start_line("a", "[\"left\",\"left\"]"), "start"); + require_rejected(start_line("a", "[\"left\",\"right\",\"left\"]"), "start"); + require_rejected(start_line("a", "[\"both\"]"), "start"); + require_rejected(start_line("a", "[\"Left\"]"), "start"); + require_rejected(start_line("a", "\"left\""), "start"); + // An array element that is not a string is a JSON-level failure, so no command name is known. + require_rejected(start_line("a", "[1]")); + require_rejected(start_line("a", "7"), "start"); + require_rejected("{\"v\":1,\"type\":\"start\",\"attempt\":\"a\",\"out_dir\":\"/s\"}", "start"); +} + +TEST_CASE("start rejects a bad out_dir", "[unit][protocol][parse]") +{ + require_rejected(start_line("a", "[\"left\"]", ""), "start"); + require_rejected(start_line("a", "[\"left\"]", "relative/dir"), "start"); + require_rejected(start_line("a", "[\"left\"]", "./dir"), "start"); + // An escaped NUL would truncate the path in C APIs. + require_rejected(start_line("a", "[\"left\"]", "/stage\\u0000/x"), "start"); + require_rejected("{\"v\":1,\"type\":\"start\",\"attempt\":\"a\",\"sides\":[\"left\"]}", "start"); + require_rejected("{\"v\":1,\"type\":\"start\",\"attempt\":\"a\",\"sides\":[\"left\"],\"out_dir\":5}", "start"); + require_rejected(start_line("a", "[\"left\"]", "/" + std::string(4096, 'x')), "start"); + + CHECK(as_start(parse_command(start_line("a", "[\"left\"]", "/" + std::string(4095, 'x'))))); +} + +TEST_CASE("attempt ids are 1-64 characters of letters, digits, underscore and dash", "[unit][protocol][parse]") +{ + CHECK(as_cancel(parse_command("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"Aa09_-\"}"))); + CHECK(as_cancel(parse_command("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"" + std::string(64, 'a') + "\"}"))); + + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"" + std::string(65, 'a') + "\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a b\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a/b\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a.b\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\\u0000\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\\u00e9\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\xC3\xA9\"}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":7}", "cancel"); + require_rejected("{\"v\":1,\"type\":\"cancel\"}", "cancel"); +} + +TEST_CASE("confirm tokens are 1-128 printable ASCII characters", "[unit][protocol][parse]") +{ + CHECK(as_confirm(parse_command(confirm_line("a")))); + CHECK(as_confirm(parse_command(confirm_line(std::string(128, 'z'))))); + CHECK(as_confirm(parse_command(confirm_line("~!#$%")))); + + require_rejected(confirm_line(""), "confirm"); + require_rejected(confirm_line(std::string(129, 'z')), "confirm"); + require_rejected(confirm_line("with space"), "confirm"); + require_rejected(confirm_line("tab\\t"), "confirm"); + require_rejected(confirm_line("nul\\u0000"), "confirm"); + require_rejected(confirm_line("del\\u007f"), "confirm"); + require_rejected(confirm_line("caf\\u00e9"), "confirm"); + require_rejected("{\"v\":1,\"type\":\"confirm\",\"attempt\":\"a\"}", "confirm"); + require_rejected("{\"v\":1,\"type\":\"confirm\",\"attempt\":\"a\",\"token\":3}", "confirm"); +} + +TEST_CASE("a rejection for a known command echoes the attempt once it is valid", "[unit][protocol][parse]") +{ + const ParsedCommand bad_sides = parse_command(start_line("att9", "[]")); + REQUIRE(bad_sides.rejection); + CHECK(bad_sides.rejection->attempt == std::optional("att9")); + + const ParsedCommand bad_token = parse_command(confirm_line("with space")); + REQUIRE(bad_token.rejection); + CHECK(bad_token.rejection->attempt == std::optional("att1")); + + const ParsedCommand bad_attempt = parse_command(start_line("a b")); + REQUIRE(bad_attempt.rejection); + CHECK_FALSE(bad_attempt.rejection->attempt.has_value()); +} + +TEST_CASE("unknown keys are rejected for every command", "[unit][protocol][parse]") +{ + require_rejected( + "{\"v\":1,\"type\":\"start\",\"attempt\":\"a\",\"sides\":[\"left\"],\"out_dir\":\"/s\"," + "\"extra\":1}", + "start"); + require_rejected("{\"v\":1,\"type\":\"confirm\",\"attempt\":\"a\",\"token\":\"t\",\"extra\":\"x\"}", "confirm"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\",\"token\":\"t\"}", "cancel"); +} + +TEST_CASE("the protocol version is required and must be the integer 1", "[unit][protocol][parse]") +{ + require_rejected("{\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + require_rejected("{\"v\":2,\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + require_rejected("{\"v\":0,\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + require_rejected("{\"v\":\"1\",\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + // Values the reader does not support at all fail before the command name is known. + require_rejected("{\"v\":1.0,\"type\":\"cancel\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":true,\"type\":\"cancel\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":null,\"type\":\"cancel\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":[1],\"type\":\"cancel\",\"attempt\":\"a\"}"); +} + +TEST_CASE("the command type must be one of the three known strings", "[unit][protocol][parse]") +{ + require_rejected("{\"v\":1,\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":1,\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":null,\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":\"Start\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":\"start \",\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":\"ready\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":\"\",\"attempt\":\"a\"}"); +} + +TEST_CASE("malformed JSON is rejected, never crashes", "[unit][protocol][json]") +{ + const std::vector lines = { + "", + " ", + "\n", + "null", + "[]", + "[{\"v\":1}]", + "\"cancel\"", + "42", + "{", + "}", + "{\"v\":1", + "{\"v\":1,}", + "{,\"v\":1}", + "{\"v\":1 \"type\":\"cancel\"}", + "{'v':1,'type':'cancel','attempt':'a'}", + "{v:1}", + "{\"v\"}", + "{\"v\":}", + "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"} trailing", + "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"}{\"v\":1}", + "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"}}", + "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a", + "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\\", + "\xEF\xBB\xBF{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"}", + }; + for (const std::string& line : lines) + { + require_rejected(line); + } +} + +TEST_CASE("duplicate keys are rejected even when the values agree", "[unit][protocol][json]") +{ + require_rejected("{\"v\":1,\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\",\"attempt\":\"b\"}"); + // A key written with an escape is still the same key. + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\",\"att\\u0065mpt\":\"b\"}"); +} + +TEST_CASE("only strings, plain integers and string arrays are accepted as values", "[unit][protocol][json]") +{ + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":{\"x\":1}}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":[1,2]}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":[[\"a\"]]}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":[\"a\",]}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":[\"a\" \"b\"]}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":true}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":false}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":null}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":NaN}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":Infinity}"); + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":-Infinity}"); +} + +TEST_CASE("numbers must be plain integers", "[unit][protocol][json]") +{ + require_rejected("{\"v\":1e400,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":1E0,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":1.5,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":1.,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":01,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":-,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":+1,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":0x1,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + // Nineteen digits would not fit the reader's int64 guard. + require_rejected("{\"v\":1000000000000000000,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + require_rejected("{\"v\":99999999999999999999999999,\"type\":\"cancel\",\"attempt\":\"a\"}", "unknown"); + // Any accepted integer that is not 1 is still the wrong version. + require_rejected("{\"v\":-1,\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + require_rejected("{\"v\":-0,\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); + require_rejected("{\"v\":999999999999999999,\"type\":\"cancel\",\"attempt\":\"a\"}", "cancel"); +} + +TEST_CASE("string escapes decode and invalid ones are rejected", "[unit][protocol][json]") +{ + // All the standard escapes decode inside a path. + const ParsedCommand escaped = parse_command(start_line("a", "[\"left\"]", "/a\\/b\\\\c\\\"d\\be\\ff\\ng\\rh\\ti")); + REQUIRE(as_start(escaped)); + CHECK(as_start(escaped)->out_dir == std::string("/a/b\\c\"d\be\ff\ng\rh\ti")); + + require_rejected(start_line("a", "[\"left\"]", "/bad\\q")); + require_rejected(start_line("a", "[\"left\"]", "/bad\\x41")); + require_rejected(start_line("a", "[\"left\"]", "/bad\\u12")); + require_rejected(start_line("a", "[\"left\"]", "/bad\\u12G4")); + require_rejected(start_line("a", "[\"left\"]", "/bad\\U0041")); +} + +TEST_CASE("unicode escapes decode to UTF-8 including surrogate pairs", "[unit][protocol][json]") +{ + const ParsedCommand two_byte = parse_command(start_line("a", "[\"left\"]", "/\\u00e9")); + REQUIRE(as_start(two_byte)); + CHECK(as_start(two_byte)->out_dir == "/\xC3\xA9"); + + const ParsedCommand three_byte = parse_command(start_line("a", "[\"left\"]", "/\\u20AC")); + REQUIRE(as_start(three_byte)); + CHECK(as_start(three_byte)->out_dir == "/\xE2\x82\xAC"); + + const ParsedCommand pair = parse_command(start_line("a", "[\"left\"]", "/\\ud83d\\ude00")); + REQUIRE(as_start(pair)); + CHECK(as_start(pair)->out_dir == "/\xF0\x9F\x98\x80"); + + const ParsedCommand upper = parse_command(start_line("a", "[\"left\"]", "/\\uD83D\\uDE00")); + REQUIRE(as_start(upper)); + CHECK(as_start(upper)->out_dir == "/\xF0\x9F\x98\x80"); + + // Raw multi-byte UTF-8 passes through unchanged. + const ParsedCommand raw = parse_command(start_line("a", "[\"left\"]", "/caf\xC3\xA9")); + REQUIRE(as_start(raw)); + CHECK(as_start(raw)->out_dir == "/caf\xC3\xA9"); +} + +TEST_CASE("unpaired surrogates are rejected", "[unit][protocol][json]") +{ + require_rejected(start_line("a", "[\"left\"]", "/\\ud83d")); + require_rejected(start_line("a", "[\"left\"]", "/\\ud83dx")); + require_rejected(start_line("a", "[\"left\"]", "/\\ud83d\\u0041")); + require_rejected(start_line("a", "[\"left\"]", "/\\ud83d\\ud83d")); + require_rejected(start_line("a", "[\"left\"]", "/\\ude00")); + require_rejected(start_line("a", "[\"left\"]", "/\\ude00\\ud83d")); +} + +TEST_CASE("raw control characters inside a string are rejected", "[unit][protocol][json]") +{ + require_rejected(start_line("a", "[\"left\"]", std::string("/bad") + '\x01' + "x")); + require_rejected(start_line("a", "[\"left\"]", std::string("/bad") + '\n' + "x")); + require_rejected(start_line("a", "[\"left\"]", std::string("/bad") + '\t' + "x")); + require_rejected(start_line("a", "[\"left\"]", std::string("/bad") + '\0' + "x")); +} + +TEST_CASE("deeply nested input is rejected without recursion problems", "[unit][protocol][json]") +{ + const std::string prefix = "{\"v\":1,\"type\":\"cancel\",\"attempt\":"; + std::string nested_objects; + for (size_t i = 0; i < 10000; ++i) + { + nested_objects += "{\"a\":"; + } + + require_rejected(prefix + std::string(60000, '[') + "}"); + require_rejected(prefix + nested_objects); + require_rejected(std::string(60000, '{')); + // Past the size limit the line is refused before any parsing. + require_rejected(prefix + std::string(200000, '[') + std::string(200000, ']') + "}"); +} + +TEST_CASE("the size limit applies to the whole line", "[unit][protocol][limits]") +{ + const std::string base = "{\"v\":1,\"type\":\"cancel\",\"attempt\":\"a\"}"; + + // Whitespace padding keeps the line valid, so the limit is the only thing under test. + std::string at_limit = base + std::string(kMaxMessageBytes - base.size(), ' '); + REQUIRE(at_limit.size() == kMaxMessageBytes); + CHECK(as_cancel(parse_command(at_limit))); + + std::string over = at_limit + " "; + const ParsedCommand parsed = parse_command(over); + REQUIRE(parsed.rejection); + CHECK_FALSE(parsed.command.has_value()); + CHECK_THAT(parsed.rejection->message, ContainsSubstring("size limit")); + CHECK(parsed.rejection->reason == RejectReason::Invalid); + + require_rejected(std::string(10 * kMaxMessageBytes, 'x')); +} + +TEST_CASE("a very long string value is rejected by the field limits, not accepted", "[unit][protocol][limits]") +{ + require_rejected("{\"v\":1,\"type\":\"cancel\",\"attempt\":\"" + std::string(60000, 'a') + "\"}", "cancel"); + require_rejected(confirm_line(std::string(60000, 't')), "confirm"); +} + +// --- serialization ------------------------------------------------------------------------------------------- + +TEST_CASE("ready serializes with and without an SDK version", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(ReadyEvent{ "1.0", std::nullopt }) == + "{\"v\":1,\"type\":\"ready\",\"worker_version\":\"1.0\",\"sdk_version\":null}"); + CHECK(serialize_event(ReadyEvent{ "1.0", std::string("5.0.1") }) == + "{\"v\":1,\"type\":\"ready\",\"worker_version\":\"1.0\",\"sdk_version\":\"5.0.1\"}"); +} + +TEST_CASE("status serializes phases and the discovered gloves", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(StatusEvent{ "a1", Phase::AwaitingStep, { { Side::Left, 10 }, { Side::Right, 20 } } }) == + "{\"v\":1,\"type\":\"status\",\"attempt\":\"a1\",\"phase\":\"awaiting_step\",\"gloves\":" + "[{\"side\":\"left\",\"glove_id\":10},{\"side\":\"right\",\"glove_id\":20}]}"); + CHECK(serialize_event(StatusEvent{ "a1", Phase::Discovering, {} }) == + "{\"v\":1,\"type\":\"status\",\"attempt\":\"a1\",\"phase\":\"discovering\",\"gloves\":[]}"); + + const std::vector> phases = { + { Phase::Discovering, "discovering" }, + { Phase::AwaitingStep, "awaiting_step" }, + { Phase::CapturingStep, "capturing_step" }, + { Phase::Saving, "saving" }, + { Phase::Stopping, "stopping" }, + }; + for (const auto& [phase, name] : phases) + { + CHECK_THAT(serialize_event(StatusEvent{ "a", phase, {} }), ContainsSubstring("\"phase\":\"" + name + "\"")); + } +} + +TEST_CASE("prompt serializes every field", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(PromptEvent{ "a1", "a1-2", Side::Right, 20, 1, 3, "Pose", "Hold flat", StepMode::Motion, 2.5 }) == + "{\"v\":1,\"type\":\"prompt\",\"attempt\":\"a1\",\"token\":\"a1-2\",\"side\":\"right\",\"glove_id\":20," + "\"index\":1,\"count\":3,\"title\":\"Pose\",\"instruction\":\"Hold flat\",\"mode\":\"motion\"," + "\"duration_s\":2.500,\"continuous\":false,\"estimated_s\":0.000}"); + CHECK(serialize_event(PromptEvent{ "a1", "a1-1", Side::Left, 4294967295U, 0, 1, "", "", StepMode::Hold, 0.0 }) == + "{\"v\":1,\"type\":\"prompt\",\"attempt\":\"a1\",\"token\":\"a1-1\",\"side\":\"left\"," + "\"glove_id\":4294967295,\"index\":0,\"count\":1,\"title\":\"\",\"instruction\":\"\",\"mode\":\"hold\"," + "\"duration_s\":0.000,\"continuous\":false,\"estimated_s\":0.000}"); +} + +TEST_CASE("a continuous prompt serializes the flag and the estimate", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(PromptEvent{ "a1", "a1-3", Side::Left, 7, 2, 8, "Open and close", "Repeat", StepMode::Hold, + 0.0, true, 10.0 }) == + "{\"v\":1,\"type\":\"prompt\",\"attempt\":\"a1\",\"token\":\"a1-3\",\"side\":\"left\",\"glove_id\":7," + "\"index\":2,\"count\":8,\"title\":\"Open and close\",\"instruction\":\"Repeat\",\"mode\":\"hold\"," + "\"duration_s\":0.000,\"continuous\":true,\"estimated_s\":10.000}"); + // The estimate keeps three decimals, so a sub-second value is not rounded away. + CHECK_THAT(serialize_event(PromptEvent{ "a", "t", Side::Right, 1, 0, 1, "", "", StepMode::Hold, 0.0, true, 0.25 }), + ContainsSubstring("\"continuous\":true,\"estimated_s\":0.250}")); +} + +TEST_CASE("step_accepted and step_progress serialize", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(StepAcceptedEvent{ "a1", "a1-1" }) == + "{\"v\":1,\"type\":\"step_accepted\",\"attempt\":\"a1\",\"token\":\"a1-1\"}"); + CHECK(serialize_event(StepProgressEvent{ "a1", "a1-2", 1.2344 }) == + "{\"v\":1,\"type\":\"step_progress\",\"attempt\":\"a1\",\"token\":\"a1-2\",\"elapsed_s\":1.234}"); + CHECK_THAT(serialize_event(StepProgressEvent{ "a1", "t", 0.0004 }), ContainsSubstring("\"elapsed_s\":0.000")); + CHECK_THAT(serialize_event(StepProgressEvent{ "a1", "t", 2.0 }), ContainsSubstring("\"elapsed_s\":2.000")); +} + +TEST_CASE("rejected serializes a null attempt and an optional token", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(RejectedEvent{ std::nullopt, "unknown", RejectReason::Invalid, "bad", std::nullopt }) == + "{\"v\":1,\"type\":\"rejected\",\"attempt\":null,\"command\":\"unknown\",\"reason\":\"invalid\"," + "\"message\":\"bad\"}"); + CHECK(serialize_event( + RejectedEvent{ std::string("a1"), "confirm", RejectReason::StaleToken, "old", std::string("a1-9") }) == + "{\"v\":1,\"type\":\"rejected\",\"attempt\":\"a1\",\"command\":\"confirm\",\"reason\":\"stale_token\"," + "\"message\":\"old\",\"token\":\"a1-9\"}"); + + const std::vector> reasons = { + { RejectReason::Invalid, "invalid" }, + { RejectReason::UnknownAttempt, "unknown_attempt" }, + { RejectReason::StaleToken, "stale_token" }, + { RejectReason::Unexpected, "unexpected" }, + { RejectReason::Busy, "busy" }, + }; + for (const auto& [reason, name] : reasons) + { + CHECK_THAT(serialize_event(RejectedEvent{ std::nullopt, "start", reason, "m", std::nullopt }), + ContainsSubstring("\"reason\":\"" + name + "\"")); + } +} + +TEST_CASE("exported serializes the file facts", "[unit][protocol][serialize]") +{ + CHECK(serialize_event(ExportedEvent{ "a1", Side::Left, 10, "a1_left.mcal", 5, std::string(64, 'a') }) == + "{\"v\":1,\"type\":\"exported\",\"attempt\":\"a1\",\"side\":\"left\",\"glove_id\":10," + "\"file\":\"a1_left.mcal\",\"bytes\":5,\"sha256\":\"" + + std::string(64, 'a') + "\"}"); + CHECK_THAT(serialize_event(ExportedEvent{ "a1", Side::Right, 1, "f", 18446744073709551615ULL, "h" }), + ContainsSubstring("\"bytes\":18446744073709551615")); +} + +TEST_CASE("result lists files only for a completed outcome", "[unit][protocol][serialize]") +{ + const std::vector files = { { Side::Left, "a1_left.mcal", 5, "h1" }, + { Side::Right, "a1_right.mcal", 7, "h2" } }; + + ResultEvent completed; + completed.attempt = "a1"; + completed.outcome = Outcome::Completed; + completed.files = files; + CHECK(serialize_event(completed) == + "{\"v\":1,\"type\":\"result\",\"attempt\":\"a1\",\"outcome\":\"completed\",\"files\":" + "[{\"side\":\"left\",\"file\":\"a1_left.mcal\",\"bytes\":5,\"sha256\":\"h1\"}," + "{\"side\":\"right\",\"file\":\"a1_right.mcal\",\"bytes\":7,\"sha256\":\"h2\"}],\"released\":true}"); + + ResultEvent empty_completed = completed; + empty_completed.files.clear(); + CHECK_THAT(serialize_event(empty_completed), ContainsSubstring("\"files\":[]")); + + // A cancelled or failed result never advertises files, even if the struct still holds some. + ResultEvent cancelled = completed; + cancelled.outcome = Outcome::Cancelled; + CHECK(serialize_event(cancelled) == + "{\"v\":1,\"type\":\"result\",\"attempt\":\"a1\",\"outcome\":\"cancelled\",\"released\":true}"); + + ResultEvent failed = completed; + failed.outcome = Outcome::Failed; + failed.error = ErrorInfo{ ErrorCode::ExportFailed, "disk full", Side::Right, 2U }; + const std::string failed_line = serialize_event(failed); + CHECK(failed_line == + "{\"v\":1,\"type\":\"result\",\"attempt\":\"a1\",\"outcome\":\"failed\",\"error\":{\"code\":" + "\"export_failed\",\"message\":\"disk full\",\"side\":\"right\",\"step\":2},\"released\":true}"); + CHECK_THAT(failed_line, !ContainsSubstring("files")); +} + +TEST_CASE("result always states whether the gloves were released, last in the object", "[unit][protocol][serialize]") +{ + ResultEvent fresh; + CHECK(fresh.released); + + ResultEvent failed; + failed.attempt = "a1"; + failed.outcome = Outcome::Failed; + failed.error = ErrorInfo{ ErrorCode::SdkError, "cannot shut down the SDK: x", std::nullopt, std::nullopt }; + failed.released = false; + CHECK(serialize_event(failed) == + "{\"v\":1,\"type\":\"result\",\"attempt\":\"a1\",\"outcome\":\"failed\",\"error\":{\"code\":" + "\"sdk_error\",\"message\":\"cannot shut down the SDK: x\"},\"released\":false}"); + + // The flag is independent of the outcome: a cancelled result can also report an unreleased SDK. + ResultEvent cancelled; + cancelled.attempt = std::nullopt; + cancelled.outcome = Outcome::Cancelled; + cancelled.released = false; + CHECK(serialize_event(cancelled) == + "{\"v\":1,\"type\":\"result\",\"attempt\":null,\"outcome\":\"cancelled\",\"released\":false}"); + + ResultEvent completed; + completed.attempt = "a1"; + completed.outcome = Outcome::Completed; + completed.released = false; + CHECK_THAT(serialize_event(completed), ContainsSubstring("\"released\":false}")); +} + +TEST_CASE("result serializes a missing attempt and error without side or step", "[unit][protocol][serialize]") +{ + ResultEvent result; + result.attempt = std::nullopt; + result.outcome = Outcome::Failed; + result.error = ErrorInfo{ ErrorCode::SdkInitFailed, "no sdk", std::nullopt, std::nullopt }; + CHECK(serialize_event(result) == + "{\"v\":1,\"type\":\"result\",\"attempt\":null,\"outcome\":\"failed\",\"error\":{\"code\":" + "\"sdk_init_failed\",\"message\":\"no sdk\"},\"released\":true}"); + + const std::vector> codes = { + { ErrorCode::SdkInitFailed, "sdk_init_failed" }, + { ErrorCode::NoGloves, "no_gloves" }, + { ErrorCode::GloveMissing, "glove_missing" }, + { ErrorCode::GloveReplaced, "glove_replaced" }, + { ErrorCode::SdkError, "sdk_error" }, + { ErrorCode::StepFailed, "step_failed" }, + { ErrorCode::ExportFailed, "export_failed" }, + { ErrorCode::ProtocolError, "protocol_error" }, + { ErrorCode::Internal, "internal" }, + }; + for (const auto& [code, name] : codes) + { + result.error = ErrorInfo{ code, "m", std::nullopt, std::nullopt }; + CHECK_THAT(serialize_event(result), ContainsSubstring("\"code\":\"" + name + "\"")); + } +} + +TEST_CASE("serialized strings escape quotes, backslashes and control characters", "[unit][protocol][serialize]") +{ + const auto line_for = [](const std::string& text) { + return serialize_event(RejectedEvent{ std::nullopt, "start", RejectReason::Invalid, text, std::nullopt }); + }; + + CHECK_THAT(line_for("a\"b"), ContainsSubstring("\"message\":\"a\\\"b\"")); + CHECK_THAT(line_for("a\\b"), ContainsSubstring("\"message\":\"a\\\\b\"")); + CHECK_THAT(line_for("a\nb"), ContainsSubstring("\"message\":\"a\\nb\"")); + CHECK_THAT(line_for("a\rb"), ContainsSubstring("\"message\":\"a\\rb\"")); + CHECK_THAT(line_for("a\tb"), ContainsSubstring("\"message\":\"a\\tb\"")); + CHECK_THAT(line_for(std::string("a") + '\x01' + "b"), ContainsSubstring("\"message\":\"a\\u0001b\"")); + CHECK_THAT(line_for(std::string("a") + '\x1f' + "b"), ContainsSubstring("\"message\":\"a\\u001fb\"")); + CHECK_THAT(line_for(std::string("a") + '\b' + "b"), ContainsSubstring("\"message\":\"a\\u0008b\"")); + CHECK_THAT(line_for(std::string("a") + '\f' + "b"), ContainsSubstring("\"message\":\"a\\u000cb\"")); + CHECK_THAT(line_for(std::string("a") + '\0' + "b"), ContainsSubstring("\"message\":\"a\\u0000b\"")); + // DEL is not a JSON control character and passes through. + CHECK_THAT( + line_for(std::string("a") + '\x7f' + "b"), ContainsSubstring(std::string("\"message\":\"a") + '\x7f' + "b\"")); +} + +TEST_CASE("a serialized event is always one line", "[unit][protocol][serialize]") +{ + const std::string nasty = std::string("line1\nline2\r\n") + '\0' + "\x01\x02 \"q\" \\ \xE2\x80\xA8 end"; + const std::vector events = { + PromptEvent{ "a", nasty, Side::Left, 1, 0, 1, nasty, nasty, StepMode::Hold, 0.0 }, + RejectedEvent{ nasty, nasty, RejectReason::Invalid, nasty, nasty }, + StatusEvent{ nasty, Phase::Saving, {} }, + }; + for (const Event& event : events) + { + const std::string line = serialize_event(event); + CHECK(line.find('\n') == std::string::npos); + CHECK(line.find('\r') == std::string::npos); + CHECK(line.find('\0') == std::string::npos); + CHECK(line.front() == '{'); + CHECK(line.back() == '}'); + } +} + +TEST_CASE("valid multi-byte UTF-8 is preserved when serialized", "[unit][protocol][serialize]") +{ + const auto message_of = [](const std::string& text) { + return serialize_event(RejectedEvent{ std::nullopt, "start", RejectReason::Invalid, text, std::nullopt }); + }; + + CHECK_THAT(message_of("caf\xC3\xA9"), ContainsSubstring("\"message\":\"caf\xC3\xA9\"")); + CHECK_THAT(message_of("\xE2\x82\xAC"), ContainsSubstring("\"message\":\"\xE2\x82\xAC\"")); + CHECK_THAT(message_of("\xF0\x9F\x98\x80"), ContainsSubstring("\"message\":\"\xF0\x9F\x98\x80\"")); + // The boundary code points of each sequence length. + CHECK_THAT(message_of("\xC2\x80"), ContainsSubstring("\"message\":\"\xC2\x80\"")); + CHECK_THAT(message_of("\xDF\xBF"), ContainsSubstring("\"message\":\"\xDF\xBF\"")); + CHECK_THAT(message_of("\xE0\xA0\x80"), ContainsSubstring("\"message\":\"\xE0\xA0\x80\"")); + CHECK_THAT(message_of("\xEF\xBF\xBF"), ContainsSubstring("\"message\":\"\xEF\xBF\xBF\"")); + CHECK_THAT(message_of("\xF0\x90\x80\x80"), ContainsSubstring("\"message\":\"\xF0\x90\x80\x80\"")); + CHECK_THAT(message_of("\xF4\x8F\xBF\xBF"), ContainsSubstring("\"message\":\"\xF4\x8F\xBF\xBF\"")); +} + +TEST_CASE("invalid UTF-8 becomes the replacement character when serialized", "[unit][protocol][serialize]") +{ + const auto message_of = [](const std::string& text) { + return serialize_event(RejectedEvent{ std::nullopt, "start", RejectReason::Invalid, text, std::nullopt }); + }; + const std::string replacement = "\xEF\xBF\xBD"; + + // A lone continuation byte, a bare lead byte, and a truncated sequence. + CHECK_THAT(message_of("a\x80z"), ContainsSubstring("\"message\":\"a" + replacement + "z\"")); + CHECK_THAT(message_of("a\xC3z"), ContainsSubstring("\"message\":\"a" + replacement + "z\"")); + CHECK_THAT(message_of("a\xE2\x82"), ContainsSubstring("\"message\":\"a" + replacement + replacement + "\"")); + // Overlong forms, surrogates and code points past U+10FFFF are not valid UTF-8. + CHECK_THAT(message_of("\xC0\x80"), ContainsSubstring("\"message\":\"" + replacement + replacement + "\"")); + CHECK_THAT(message_of("\xC1\xBF"), ContainsSubstring("\"message\":\"" + replacement + replacement + "\"")); + CHECK_THAT(message_of("\xE0\x80\x80"), + ContainsSubstring("\"message\":\"" + replacement + replacement + replacement + "\"")); + CHECK_THAT(message_of("\xED\xA0\x80"), + ContainsSubstring("\"message\":\"" + replacement + replacement + replacement + "\"")); + CHECK_THAT(message_of("\xF4\x90\x80\x80"), + ContainsSubstring("\"message\":\"" + replacement + replacement + replacement + replacement + "\"")); + CHECK_THAT(message_of("\xF8\x88\x80\x80\x80"), ContainsSubstring(replacement)); + CHECK_THAT(message_of("\xFF\xFE"), ContainsSubstring("\"message\":\"" + replacement + replacement + "\"")); +} + +TEST_CASE("non-finite seconds never serialize as bare nan or inf", "[unit][protocol][serialize]") +{ + const double inf = std::numeric_limits::infinity(); + const std::vector lines = { + serialize_event(StepProgressEvent{ "a", "t", std::nan("") }), + serialize_event(StepProgressEvent{ "a", "t", inf }), + serialize_event(StepProgressEvent{ "a", "t", -inf }), + serialize_event(PromptEvent{ "a", "t", Side::Left, 1, 0, 1, "", "", StepMode::Motion, inf }), + }; + for (const std::string& line : lines) + { + INFO("line: " << line); + CHECK_THAT(line, !ContainsSubstring("nan")); + CHECK_THAT(line, !ContainsSubstring("inf")); + } +} diff --git a/tests/cpp/plugins/manus_calibration/test_sha256.cpp b/tests/cpp/plugins/manus_calibration/test_sha256.cpp new file mode 100644 index 0000000000..15930aa253 --- /dev/null +++ b/tests/cpp/plugins/manus_calibration/test_sha256.cpp @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include + +namespace +{ + +using plugins::manus::calibration::sha256_hex; + +std::vector bytes_of(const std::string& text) +{ + return std::vector(text.begin(), text.end()); +} + +std::vector repeated(uint8_t value, size_t count) +{ + return std::vector(count, value); +} + +} // namespace + +TEST_CASE("sha256 matches the standard test vectors", "[unit][sha256]") +{ + CHECK(sha256_hex({}) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(sha256_hex(bytes_of("abc")) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + CHECK(sha256_hex(bytes_of("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")) == + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); + CHECK(sha256_hex(bytes_of("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrl" + "mnopqrsmnopqrstnopqrstu")) == + "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1"); +} + +TEST_CASE("sha256 is correct on either side of every padding boundary", "[unit][sha256]") +{ + // Lengths around the 55/56 byte point where padding spills into a second block, and around full blocks. + CHECK(sha256_hex(repeated('a', 54)) == "a3f01b6939256127582ac8ae9fb47a382a244680806a3f613a118851c1ca1d47"); + CHECK(sha256_hex(repeated('a', 55)) == "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"); + CHECK(sha256_hex(repeated('a', 56)) == "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"); + CHECK(sha256_hex(repeated('a', 57)) == "f13b2d724659eb3bf47f2dd6af1accc87b81f09f59f2b75e5c0bed6589dfe8c6"); + CHECK(sha256_hex(repeated('a', 63)) == "7d3e74a05d7db15bce4ad9ec0658ea98e3f06eeecf16b4c6fff2da457ddc2f34"); + CHECK(sha256_hex(repeated('a', 64)) == "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"); + CHECK(sha256_hex(repeated('a', 65)) == "635361c48bb9eab14198e76ea8ab7f1a41685d6ad62aa9146d301d4f17eb0ae0"); + CHECK(sha256_hex(repeated('a', 119)) == "31eba51c313a5c08226adf18d4a359cfdfd8d2e816b13f4af952f7ea6584dcfb"); + CHECK(sha256_hex(repeated('a', 120)) == "2f3d335432c70b580af0e8e1b3674a7c020d683aa5f73aaaedfdc55af904c21c"); + CHECK(sha256_hex(repeated('a', 127)) == "c57e9278af78fa3cab38667bef4ce29d783787a2f731d4e12200270f0c32320a"); + CHECK(sha256_hex(repeated('a', 128)) == "6836cf13bac400e9105071cd6af47084dfacad4e5e302c94bfed24e013afb73e"); + CHECK(sha256_hex(repeated('a', 129)) == "c12cb024a2e5551cca0e08fce8f1c5e314555cc3fef6329ee994a3db752166ae"); +} + +TEST_CASE("sha256 handles binary data and large inputs", "[unit][sha256]") +{ + std::vector pattern(1000); + for (size_t i = 0; i < pattern.size(); ++i) + { + pattern[i] = static_cast(i % 251); + } + CHECK(sha256_hex(pattern) == "4e4c294b331f7a2099a379bec34b9f9fc03dc46ab465d998f4d683da53487e6d"); + CHECK(sha256_hex(repeated('a', 1000000)) == "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); +} + +TEST_CASE("sha256 output is 64 lowercase hex characters", "[unit][sha256]") +{ + const std::string digest = sha256_hex(bytes_of("any input")); + REQUIRE(digest.size() == 64); + for (const char c : digest) + { + CHECK(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); + } +} diff --git a/tests/python/examples/CMakeLists.txt b/tests/python/examples/CMakeLists.txt index a0cc7b40cc..7cf07a9f4c 100644 --- a/tests/python/examples/CMakeLists.txt +++ b/tests/python/examples/CMakeLists.txt @@ -3,5 +3,6 @@ add_subdirectory(camera_viz) add_subdirectory(deviceio_live_view) +add_subdirectory(manus_calibration) add_subdirectory(soma_body_publisher) add_subdirectory(soma_hand_publisher) diff --git a/tests/python/examples/manus_calibration/CMakeLists.txt b/tests/python/examples/manus_calibration/CMakeLists.txt new file mode 100644 index 0000000000..cdd3dca746 --- /dev/null +++ b/tests/python/examples/manus_calibration/CMakeLists.txt @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +file(GLOB TEST_FILES + RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/test_*.py" +) + +foreach(test_file ${TEST_FILES}) + get_filename_component(test_name "${test_file}" NAME_WE) + add_test( + NAME "manus_calibration_${test_name}" + COMMAND uv run --python ${ISAAC_TELEOP_PYTHON_VERSION} --extra dev + pytest -v --tb=short "${test_file}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + set_tests_properties("manus_calibration_${test_name}" PROPERTIES + LABELS "manus_calibration;unit" + ) +endforeach() diff --git a/tests/python/examples/manus_calibration/conftest.py b/tests/python/examples/manus_calibration/conftest.py new file mode 100644 index 0000000000..89c87d932a --- /dev/null +++ b/tests/python/examples/manus_calibration/conftest.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from repo_paths import repo_root # noqa: E402 + +sys.path.insert(0, str(repo_root() / "examples" / "manus_calibration" / "python")) +sys.path.insert( + 0, str(repo_root() / "src" / "plugins" / "manus_calibration" / "python") +) + +from isaaccapture_manus_calibration import protocol as proto # noqa: E402 +from isaaccapture_manus_calibration.worker import ( # noqa: E402 + AttemptState, + CalibrationAttempt, + CalibrationOutcome, + CalibrationWorker, +) + + +class Folder: + """The plain output folder under test: what calibrations left in it.""" + + def __init__(self, directory: Path) -> None: + self.directory = directory + + def saved_attempts(self) -> list[str]: + """One entry per attempt that left .mcal files, so a pair counts once.""" + if not self.directory.is_dir(): + return [] + names = sorted(p.name for p in self.directory.glob("*.mcal")) + return sorted({name.split("_")[0] for name in names}) + + def leftovers(self) -> list[str]: + """Anything in the folder that is not a finished .mcal file, such as a half-written one.""" + if not self.directory.is_dir(): + return [] + return sorted( + p.name for p in self.directory.iterdir() if not p.name.endswith(".mcal") + ) + + +FAKE_WORKER = Path(__file__).with_name("fake_worker.py") +BOTH = (proto.Side.LEFT, proto.Side.RIGHT) + + +@pytest.fixture +def commands_log(tmp_path: Path) -> Path: + return tmp_path / "commands.log" + + +@pytest.fixture +def make_worker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, commands_log: Path): + """Build a CalibrationWorker around the scripted fake, with short supervision timeouts.""" + + def factory( + scenario: dict | None = None, **settings + ) -> tuple[CalibrationWorker, Folder]: + wrapper = tmp_path / "fake-calibrator" + wrapper.write_text( + f"#!{sys.executable}\nimport runpy\nrunpy.run_path({str(FAKE_WORKER)!r}, run_name='__main__')\n" + ) + wrapper.chmod(0o755) + monkeypatch.setenv("FAKE_WORKER_SCENARIO", json.dumps(scenario or {})) + monkeypatch.setenv("FAKE_WORKER_LOG", str(commands_log)) + folder = Folder(tmp_path / "calibrations") + timeouts = { + "ready_timeout_s": 5, + "cancel_grace_s": 2, + "exit_grace_s": 2, + "term_grace_s": 1, + "kill_wait_s": 2, + } + return CalibrationWorker( + wrapper, folder.directory, **{**timeouts, **settings} + ), folder + + return factory + + +def commands(path: Path) -> list[str]: + """Command types the fake worker received, in order.""" + return path.read_text().split() if path.exists() else [] + + +async def drive( + attempt: CalibrationAttempt, + sides=BOTH, + *, + until: type | None = None, + timeout: float = 20.0, +) -> CalibrationOutcome: + """Start ``attempt`` and confirm every prompt until it settles.""" + await attempt.start(sides) + confirmed: set[str] = set() + async with asyncio.timeout(timeout): + while attempt.state is not AttemptState.DONE: + prompt = attempt.prompt + if prompt is not None and prompt.token not in confirmed: + confirmed.add(prompt.token) + try: + await attempt.confirm(prompt.token) + except Exception: + # A refused or abandoned confirm is part of what these drivers exercise; the loop + # ends on the attempt reaching DONE, and the caller inspects the outcome. + pass + await asyncio.sleep(0.01) + return await attempt.outcome() + + +async def wait_for(predicate, timeout: float = 10.0) -> None: + async with asyncio.timeout(timeout): + # The predicate reads plain attributes, so there is no event to await. + while not predicate(): # noqa: ASYNC110 + await asyncio.sleep(0.01) diff --git a/tests/python/examples/manus_calibration/fake_worker.py b/tests/python/examples/manus_calibration/fake_worker.py new file mode 100644 index 0000000000..3653620282 --- /dev/null +++ b/tests/python/examples/manus_calibration/fake_worker.py @@ -0,0 +1,479 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A scripted stand-in for ``manus_glove_calibrator`` that speaks the worker contract. + +Behavior comes from the JSON object in ``FAKE_WORKER_SCENARIO``; ``FAKE_WORKER_LOG`` names a file +that receives one line per command the fake reads, so tests can count what reached the worker. +A step may set ``"continuous": true`` with ``"estimated": SECONDS`` (and ``"time": 0``) to model the SDK's +continuous step, which is a hold-paced prompt that carries an estimated duration. +``reject_start`` refuses the start command and stays idle; ``idle_cancel_result`` makes SIGTERM before ``start`` +report a cancelled result with no attempt id. +Hang scenarios keep the process alive and silent (``ignore_term`` makes it survive SIGTERM too): +``hang_after_start``, ``hang_after_discovering``, ``hang_on_confirm`` and ``hang_after_last_accept``. +``chatty_discovery`` and ``chatty_finish`` (seconds) keep emitting status events while the worker is busy. +Every ``result`` carries ``released``: true unless the scenario sets ``not_released``, which models a worker +whose SDK calibration could not be stopped or whose SDK could not be shut down. +A continuous step is held for its estimate only when run with ``--continuous-pacing=window``. +Run as ``python fake_worker.py --ipc-fd=N [--continuous-pacing=window]``. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import select +import signal +import socket +import sys +import time + +SCENARIO = json.loads(os.environ.get("FAKE_WORKER_SCENARIO", "{}")) +LOG_PATH = os.environ.get("FAKE_WORKER_LOG") +STEPS = SCENARIO.get( + "steps", + [ + {"title": "Flat hand", "instruction": "Lay your hand flat.", "time": 0.0}, + {"title": "Wave", "instruction": "Wave your fingers.", "time": 0.4}, + ], +) +GLOVE_IDS = {"left": 11, "right": 22} + + +class Cancelled(Exception): + pass + + +def released() -> bool: + """The `released` flag every result carries.""" + return not SCENARIO.get("not_released") + + +def hang() -> None: + """Stay alive and silent, as a worker blocked inside the SDK would.""" + time.sleep(60) + + +def chatter(channel: "Channel", phase: str, seconds: float, gloves: list) -> None: + """Emit a status event every 0.2 s for ``seconds``, modelling a slow but live worker.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + channel.status(phase, gloves) + channel.sleep(0.2) + + +# Set once the first confirmation has been refused by the `reject_first_confirm` scenario. +_REJECTED_ONCE = False + + +def flood(total: int) -> None: + """Write about ``total`` bytes of short lines plus one overlong line to stdout and stderr.""" + line = "x" * 99 + "\n" + # Looks like an IPC message; it travels on stdout, which is never parsed. + decoy = '{"v": 1, "type": "result", "attempt": null, "outcome": "failed", "error": {"code": "internal", "message": "decoy"}}\n' + for stream in (sys.stdout, sys.stderr): + stream.write(decoy) + for _ in range(total // len(line)): + stream.write(line) + stream.write("y" * 5000 + "\n") + stream.flush() + + +def main() -> int: + fd = next( + int(a.split("=", 1)[1]) for a in sys.argv[1:] if a.startswith("--ipc-fd=") + ) + sock = socket.socket(fileno=fd) + channel = Channel(sock) + print("fake worker log line on stdout", flush=True) + print("fake worker log line on stderr", file=sys.stderr, flush=True) + if SCENARIO.get("noise_bytes"): + flood(SCENARIO["noise_bytes"]) + if SCENARIO.get("exit_before_ready"): + return 3 + if SCENARIO.get("ignore_term"): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + if SCENARIO.get("term_exits_zero"): + # A worker that shuts down "cleanly" when terminated, so its exit code alone looks like success. + signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) + if SCENARIO.get("idle_cancel_result"): + # Like the real worker: SIGTERM before `start` reports a cancelled result with no attempt id. + def report_cancelled(*_): + channel.send( + { + "type": "result", + "attempt": None, + "outcome": "cancelled", + "released": True, + } + ) + os._exit(0) + + signal.signal(signal.SIGTERM, report_cancelled) + if SCENARIO.get("never_ready"): + time.sleep(60) + return 0 + if SCENARIO.get("garbage_ready"): + channel.raw(b"this is not json\n") + time.sleep(60) + return 0 + channel.send({"type": "ready", "worker_version": "fake-1", "sdk_version": None}) + start = channel.read_command("start") + attempt = start["attempt"] + channel.attempt = attempt + if SCENARIO.get("reject_start"): + # The real worker refuses start (for example an unusable directory) and stays idle. + channel.send( + { + "type": "rejected", + "attempt": attempt, + "command": "start", + "reason": "invalid", + "message": "out_dir is not a writable directory", + } + ) + time.sleep(60) + return 0 + try: + return run_attempt(channel, attempt, start) + except Cancelled: + return cancelled(channel, attempt) + + +def run_attempt(channel: "Channel", attempt: str, start: dict) -> int: + sides = start["sides"] + out_dir = start["out_dir"] + if SCENARIO.get("hang_after_start"): + hang() + if SCENARIO.get("malformed_line"): + channel.raw(b"{bad json\n") + time.sleep(60) + if SCENARIO.get("oversized"): + channel.raw(b'{"v":1,"type":"status","pad":"' + b"x" * 70000 + b'"}\n') + time.sleep(60) + if SCENARIO.get("unknown_event"): + channel.send({"type": "mystery", "attempt": attempt}) + time.sleep(60) + if SCENARIO.get("spurious_accept"): + channel.send( + {"type": "step_accepted", "attempt": attempt, "token": "never-offered"} + ) + time.sleep(60) + if SCENARIO.get("unrequested_side_prompt"): + other = "right" if "right" not in sides else "left" + channel.send( + { + "type": "prompt", + "attempt": attempt, + "token": "x-0", + "side": other, + "glove_id": GLOVE_IDS[other], + "index": 0, + "count": 1, + "title": "t", + "instruction": "i", + "mode": "hold", + "duration_s": 0.0, + "continuous": False, + "estimated_s": 0.0, + } + ) + time.sleep(60) + if SCENARIO.get("wrong_attempt"): + channel.send( + { + "type": "status", + "attempt": "other-attempt", + "phase": "discovering", + "gloves": [], + } + ) + time.sleep(60) + channel.status("discovering", []) + if SCENARIO.get("hang_after_discovering"): + hang() + if SCENARIO.get("chatty_discovery"): + chatter(channel, "discovering", SCENARIO["chatty_discovery"], []) + if SCENARIO.get("discover_delay"): + channel.sleep(SCENARIO["discover_delay"]) + gloves = [{"side": s, "glove_id": GLOVE_IDS[s]} for s in sides] + channel.status("discovering", gloves) + exported = [] + for side in sides: + for index, step in enumerate(STEPS): + token = f"{side}-{index}" + channel.send( + { + "type": "prompt", + "attempt": attempt, + "token": token, + "side": side, + "glove_id": GLOVE_IDS[side], + "index": index, + "count": len(STEPS), + "title": step["title"], + "instruction": step["instruction"], + "mode": "motion" if step["time"] > 0 else "hold", + "duration_s": step["time"], + "continuous": bool(step.get("continuous", False)), + "estimated_s": float(step.get("estimated", 0.0)), + } + ) + channel.status("awaiting_step", gloves) + if SCENARIO.get("crash_after_prompt"): + os._exit(9) + confirm_step(channel, attempt, token, step) + last = side == sides[-1] and index == len(STEPS) - 1 + if last and SCENARIO.get("hang_after_last_accept"): + hang() + if last and SCENARIO.get("chatty_finish"): + chatter(channel, "saving", SCENARIO["chatty_finish"], gloves) + if SCENARIO.get("fail_at_step") == [side, index]: + return fail( + channel, + attempt, + "step_failed", + "the SDK refused the step", + side, + index, + ) + if SCENARIO.get("save_delay"): + channel.status("saving", gloves) + channel.sleep(SCENARIO["save_delay"]) + exported.append(export(channel, attempt, out_dir, side)) + if SCENARIO.get("fail_after_first_export"): + return fail( + channel, attempt, "export_failed", "second glove failed", None, None + ) + channel.status("saving", gloves) + if SCENARIO.get("no_result"): + return 0 + files = [{k: e[k] for k in ("side", "file", "bytes", "sha256")} for e in exported] + if SCENARIO.get("result_mismatch"): + files[0]["bytes"] += 1 + if SCENARIO.get("partial_result"): + files = files[:1] + channel.send( + { + "type": "result", + "attempt": attempt, + "outcome": "completed", + "files": files, + "released": released(), + } + ) + if SCENARIO.get("extra_after_result"): + channel.status("saving", gloves) + if SCENARIO.get("hang_after_result"): + time.sleep(60) + return int(SCENARIO.get("exit_code_after_result", 0)) + + +def confirm_step(channel: "Channel", attempt: str, token: str, step: dict) -> None: + """Wait for the confirmation of ``token``, refusing anything else, then run the step.""" + global _REJECTED_ONCE + accepted = False + while not accepted: + command = channel.read_command("confirm", "cancel") + if command["type"] == "cancel": + if SCENARIO.get("ignore_cancel"): + time.sleep(60) + raise Cancelled + if SCENARIO.get("hang_on_confirm"): + hang() + if SCENARIO.get("reject_first_confirm") and not _REJECTED_ONCE: + _REJECTED_ONCE = True + channel.send( + { + "type": "rejected", + "attempt": attempt, + "command": "confirm", + "reason": "unexpected", + "message": "not now", + "token": command["token"], + } + ) + continue + if command["token"] != token: + channel.send( + { + "type": "rejected", + "attempt": attempt, + "command": "confirm", + "reason": "stale_token", + "message": "not the offered prompt", + "token": command["token"], + } + ) + continue + channel.send({"type": "step_accepted", "attempt": attempt, "token": token}) + accepted = True + window = step_window(step) + if window > 0: + elapsed = 0.0 + channel.status("capturing_step", []) + if SCENARIO.get("progress_overrun"): + channel.send( + { + "type": "step_progress", + "attempt": attempt, + "token": token, + "elapsed_s": window + 5, + } + ) + time.sleep(60) + while elapsed < window: + channel.sleep(0.1) + elapsed = min(window, elapsed + 0.1) + channel.send( + { + "type": "step_progress", + "attempt": attempt, + "token": token, + "elapsed_s": elapsed, + } + ) + + +def step_window(step: dict) -> float: + """Seconds the worker holds a step after accepting it: a motion's duration, or a continuous step's estimate + when the worker runs with ``--continuous-pacing=window``.""" + if step["time"] > 0: + return step["time"] + if step.get("continuous") and "--continuous-pacing=window" in sys.argv[1:]: + return float(step.get("estimated", 0.0)) + return 0.0 + + +def export(channel: "Channel", attempt: str, out_dir: str, side: str) -> dict: + empty = SCENARIO.get("empty_export") or SCENARIO.get("empty_side") == side + data = b"" if empty else (b"MCAL-" + side.encode()) * 64 + name = f"{attempt}_{side}.mcal" + path = os.path.join(out_dir, name) + if SCENARIO.get("symlink_side") == side: + os.symlink(SCENARIO["symlink_target"], path) + elif SCENARIO.get("directory_side") == side: + os.mkdir(path) + elif not SCENARIO.get("missing_file"): + with open(path + ".part", "wb") as handle: + handle.write(data) + os.rename(path + ".part", path) + corrupt = SCENARIO.get("bad_sha") or SCENARIO.get("bad_sha_side") == side + digest = hashlib.sha256(b"different" if corrupt else data).hexdigest() + reported = ( + SCENARIO.get("reported_name", name) + if SCENARIO.get("name_side", side) == side + else name + ) + event = { + "type": "exported", + "attempt": attempt, + "side": side, + "glove_id": GLOVE_IDS[side], + "file": reported, + "bytes": len(data), + "sha256": digest, + } + if not SCENARIO.get("no_export_events"): + channel.send(event) + return event + + +def fail(channel: "Channel", attempt: str, code: str, message: str, side, step) -> int: + error = {"code": code, "message": message} + if side is not None: + error.update(side=side, step=step) + channel.send( + { + "type": "result", + "attempt": attempt, + "outcome": "failed", + "error": error, + "released": released(), + } + ) + return 1 + + +def cancelled(channel: "Channel", attempt: str) -> int: + # A worker that needs time to stop its SDK after a cancel. + time.sleep(float(SCENARIO.get("slow_cancel", 0))) + channel.status("stopping", []) + channel.send( + { + "type": "result", + "attempt": attempt, + "outcome": "cancelled", + "released": released(), + } + ) + return 0 + + +class Channel: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buffer = b"" + self.attempt: str | None = None + + def raw(self, data: bytes) -> None: + self.sock.sendall(data) + + def send(self, message: dict) -> None: + self.raw((json.dumps({"v": 1, **message}) + "\n").encode()) + + def status(self, phase: str, gloves: list) -> None: + self.send( + { + "type": "status", + "attempt": self.attempt, + "phase": phase, + "gloves": gloves, + } + ) + + def poll_command(self, timeout: float) -> dict | None: + """Return the next buffered or arriving command, or None when ``timeout`` passes.""" + while b"\n" not in self.buffer: + ready, _, _ = select.select([self.sock], [], [], timeout) + if not ready: + return None + chunk = self.sock.recv(4096) + if not chunk: + raise Cancelled + self.buffer += chunk + line, self.buffer = self.buffer.split(b"\n", 1) + command = json.loads(line) + if LOG_PATH: + with open(LOG_PATH, "a", encoding="utf-8") as log: + log.write(command["type"] + "\n") + return command + + def read_command(self, *types: str) -> dict: + while True: + command = self.poll_command(30.0) + if command is None: + raise SystemExit("fake worker timed out waiting for a command") + if command["type"] == "cancel" and "cancel" not in types: + if SCENARIO.get("ignore_cancel"): + continue + raise Cancelled + if command["type"] in types: + return command + + def sleep(self, seconds: float) -> None: + """Sleep while still honoring a cancel command.""" + deadline = time.monotonic() + seconds + while (remaining := deadline - time.monotonic()) > 0: + command = self.poll_command(min(0.05, remaining)) + if ( + command is not None + and command["type"] == "cancel" + and not SCENARIO.get("ignore_cancel") + ): + raise Cancelled + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/python/examples/manus_calibration/pyproject.toml b/tests/python/examples/manus_calibration/pyproject.toml new file mode 100644 index 0000000000..7d6783da57 --- /dev/null +++ b/tests/python/examples/manus_calibration/pyproject.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "manus-calibration-tests" +version = "0.0.0" +requires-python = ">=3.11,<3.14" + +[project.optional-dependencies] +dev = ["pytest"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tests/python/examples/manus_calibration/test_controller.py b/tests/python/examples/manus_calibration/test_controller.py new file mode 100644 index 0000000000..987b5df246 --- /dev/null +++ b/tests/python/examples/manus_calibration/test_controller.py @@ -0,0 +1,556 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Ownership, stage and confirmation rules of the controller, over a real fake-worker process.""" + +from __future__ import annotations + +import asyncio + +import pytest +from conftest import commands, wait_for +from isaaccapture_examples.manus_calibration.controller import ( + CalibrationController, + NotOwner, + Stage, +) +from isaaccapture_manus_calibration import protocol as proto +from isaaccapture_manus_calibration.worker import AttemptError, OutcomeKind + +HOLD = { + "steps": [ + {"title": "A", "instruction": "a", "time": 0.0}, + {"title": "B", "instruction": "b", "time": 0.0}, + ] +} +ONE_HOLD = {"steps": [{"title": "A", "instruction": "a", "time": 0.0}]} +LEFT = (proto.Side.LEFT,) +BOTH = (proto.Side.LEFT, proto.Side.RIGHT) +OWNER = "owner-client-0001" +OTHER = "other-client-0002" + + +def build(make_worker, scenario=None, **settings): + """A controller over a fake worker, plus the views it announced.""" + worker, store = make_worker({**HOLD, **(scenario or {})}, **settings) + views = [] + return CalibrationController(worker, views.append), store, views + + +async def at_prompt(controller: CalibrationController) -> None: + await wait_for( + lambda: ( + controller.view().stage is Stage.AWAITING_CONFIRMATION + and controller.view().prompt is not None + ) + ) + + +async def finish(controller: CalibrationController, client: str = OWNER): + """Confirm each prompt as ``client`` until the attempt settles; returns the last view.""" + confirmed: set[str] = set() + async with asyncio.timeout(30): + while controller.view().stage is not Stage.FINISHED: + view = controller.view() + prompt = view.prompt + if ( + prompt + and prompt.token not in confirmed + and view.stage is Stage.AWAITING_CONFIRMATION + ): + confirmed.add(prompt.token) + await controller.confirm(client, prompt.token) + await asyncio.sleep(0.01) + return controller.view() + + +def is_subsequence(wanted, seen) -> bool: + iterator = iter(seen) + return all(any(item == other for other in iterator) for item in wanted) + + +def test_a_new_controller_is_idle(make_worker): + controller, _store, views = build(make_worker) + view = controller.view() + assert view.stage is Stage.IDLE + assert view.owner is None + assert view.prompt is None + assert view.outcome is None + assert controller.active is False + assert views == [] + + +def test_start_assigns_the_owner_and_reaches_the_first_prompt(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + view = controller.view() + assert controller.active is True + assert view.owner == OWNER + assert view.sides == LEFT + assert view.prompt.side is proto.Side.LEFT + assert view.prompt.index == 0 + assert [g.side for g in view.gloves] == [proto.Side.LEFT] + await controller.aclose() + + asyncio.run(scenario()) + + +def test_the_sides_are_reported_in_worker_order_whatever_order_was_asked(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, (proto.Side.RIGHT, proto.Side.LEFT)) + assert controller.view().sides == BOTH + await controller.aclose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("second", [OWNER, OTHER]) +def test_a_second_start_is_refused_and_changes_nothing(make_worker, second): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + before = controller.view() + with pytest.raises(AttemptError, match="already active"): + await controller.start(second, BOTH) + after = controller.view() + assert after.owner == OWNER + assert after.sides == before.sides + assert after.prompt == before.prompt + await controller.aclose() + + asyncio.run(scenario()) + + +def test_only_the_owner_may_confirm_or_cancel(make_worker, commands_log): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + token = controller.view().prompt.token + with pytest.raises(NotOwner): + await controller.confirm(OTHER, token) + with pytest.raises(NotOwner): + await controller.cancel(OTHER) + view = controller.view() + assert view.stage is Stage.AWAITING_CONFIRMATION + assert view.confirm_pending is False + assert "confirm" not in commands(commands_log) + assert "cancel" not in commands(commands_log) + await controller.aclose() + + asyncio.run(scenario()) + + +def test_confirm_and_cancel_with_nothing_active_are_refused(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker) + with pytest.raises(AttemptError, match="no calibration attempt"): + await controller.confirm(OWNER, "left-0") + with pytest.raises(AttemptError, match="no calibration attempt"): + await controller.cancel(OWNER) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("token", ["wrong-token", "", "right-0"]) +def test_a_wrong_or_stale_token_is_refused_without_a_step( + make_worker, commands_log, token +): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + with pytest.raises(AttemptError, match="no longer current"): + await controller.confirm(OWNER, token) + assert controller.view().confirm_pending is False + assert "confirm" not in commands(commands_log) + await controller.aclose() + + asyncio.run(scenario()) + + +def test_a_superseded_prompt_token_is_refused_after_the_next_prompt_appears( + make_worker, commands_log +): + async def scenario(): + controller, _store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + first = controller.view().prompt + await controller.confirm(OWNER, first.token) + await wait_for( + lambda: ( + controller.view().prompt is not None + and controller.view().prompt.token != first.token + and controller.view().stage is Stage.AWAITING_CONFIRMATION + ) + ) + sent = commands(commands_log).count("confirm") + with pytest.raises(AttemptError, match="no longer current"): + await controller.confirm(OWNER, first.token) + assert commands(commands_log).count("confirm") == sent + await controller.aclose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "sides", [LEFT, (proto.Side.RIGHT,), BOTH], ids=["left", "right", "both"] +) +def test_a_full_run_completes_publishes_and_releases_ownership(make_worker, sides): + async def scenario(): + controller, store, _views = build(make_worker) + await controller.start(OWNER, sides) + view = await finish(controller) + assert view.stage is Stage.FINISHED + assert view.owner is None + assert view.prompt is None + assert view.outcome.kind is OutcomeKind.COMPLETED + assert {f.side for f in view.outcome.files} == set(sides) + assert len(store.saved_attempts()) == 1 + assert controller.active is False + assert "completed" in view.notice + + asyncio.run(scenario()) + + +def test_a_new_attempt_can_start_after_a_finished_one(make_worker): + async def scenario(): + controller, store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await finish(controller) + await controller.start(OTHER, LEFT) + assert controller.view().owner == OTHER + await finish(controller, OTHER) + assert len(store.saved_attempts()) == 2 + + asyncio.run(scenario()) + + +def test_stages_advance_in_order_and_motion_reports_progress(make_worker): + async def scenario(): + steps = {"steps": [{"title": "W", "instruction": "w", "time": 0.4}]} + controller, _store, views = build(make_worker, steps) + await controller.start(OWNER, LEFT) + await finish(controller) + stages = [v.stage for v in views] + assert is_subsequence( + [ + Stage.STARTING, + Stage.DISCOVERING, + Stage.AWAITING_CONFIRMATION, + Stage.SAMPLING, + Stage.FINISHED, + ], + stages, + ) + elapsed = [v.elapsed_s for v in views if v.elapsed_s is not None] + assert elapsed + assert max(elapsed) <= 0.4 + 1e-6 + assert elapsed == sorted(elapsed) + + asyncio.run(scenario()) + + +def test_confirm_pending_is_raised_while_the_worker_decides_then_cleared( + make_worker, +): + async def scenario(): + controller, _store, views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + await controller.confirm(OWNER, controller.view().prompt.token) + assert any(v.confirm_pending for v in views) + assert controller.view().confirm_pending is False + await controller.aclose() + + asyncio.run(scenario()) + + +def test_a_second_confirm_while_one_is_in_flight_sends_only_one_command( + make_worker, commands_log +): + async def scenario(): + controller, _store, _views = build(make_worker, ONE_HOLD) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + token = controller.view().prompt.token + await asyncio.gather( + controller.confirm(OWNER, token), controller.confirm(OWNER, token) + ) + assert commands(commands_log).count("confirm") == 1 + await finish(controller) + + asyncio.run(scenario()) + + +def test_a_refused_confirmation_becomes_a_notice_and_can_be_retried(make_worker): + async def scenario(): + controller, _store, _views = build( + make_worker, {**ONE_HOLD, "reject_first_confirm": True} + ) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + token = controller.view().prompt.token + await controller.confirm(OWNER, token) + view = controller.view() + assert view.notice + assert view.confirm_pending is False + assert view.stage is Stage.AWAITING_CONFIRMATION + await controller.confirm(OWNER, token) + view = await finish(controller) + assert view.outcome.kind is OutcomeKind.COMPLETED + + asyncio.run(scenario()) + + +def test_cancel_by_the_owner_ends_cancelled_and_publishes_nothing(make_worker): + async def scenario(): + controller, store, views = build(make_worker) + await controller.start(OWNER, BOTH) + await at_prompt(controller) + await controller.cancel(OWNER) + assert any(v.stage is Stage.STOPPING for v in views) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + view = controller.view() + assert view.outcome.kind is OutcomeKind.CANCELLED + assert view.outcome.files == () + assert store.saved_attempts() == [] + assert controller.active is False + + asyncio.run(scenario()) + + +def test_only_the_owner_disconnecting_cancels_the_attempt(make_worker): + async def scenario(): + controller, store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + await controller.client_disconnected(OTHER) + await asyncio.sleep(0.3) + assert controller.view().stage is Stage.AWAITING_CONFIRMATION + assert controller.active is True + await controller.client_disconnected(OWNER) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + assert controller.view().outcome.kind is OutcomeKind.CANCELLED + assert store.saved_attempts() == [] + + asyncio.run(scenario()) + + +def test_a_disconnect_with_nothing_active_is_ignored(make_worker): + async def scenario(): + controller, _store, views = build(make_worker) + await controller.client_disconnected(OWNER) + assert views == [] + + asyncio.run(scenario()) + + +def test_a_completed_attempts_files_survive_the_owner_disconnecting_later( + make_worker, +): + async def scenario(): + controller, store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await finish(controller) + await controller.client_disconnected(OWNER) + assert len(store.saved_attempts()) == 1 + assert controller.view().outcome.kind is OutcomeKind.COMPLETED + + asyncio.run(scenario()) + + +def test_aclose_cancels_an_active_attempt_and_waits_for_the_worker(make_worker): + async def scenario(): + controller, store, _views = build(make_worker) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + await controller.aclose() + outcome = controller.view().outcome + assert outcome.kind is OutcomeKind.CANCELLED + assert outcome.exit_code == 0 + assert outcome.forced_termination is False + assert store.saved_attempts() == [] + + asyncio.run(scenario()) + + +def test_aclose_with_nothing_active_is_a_no_op(make_worker): + async def scenario(): + controller, _store, views = build(make_worker) + await controller.aclose() + await controller.aclose() + assert views == [] + + asyncio.run(scenario()) + + +def test_a_failed_run_reports_the_failure_and_publishes_nothing(make_worker): + async def scenario(): + controller, store, _views = build(make_worker, {"fail_at_step": ["left", 0]}) + await controller.start(OWNER, LEFT) + view = await finish(controller) + assert view.outcome.kind is OutcomeKind.FAILED + assert view.outcome.error.code == "step_failed" + assert "failed" in view.notice + assert view.outcome.files == () + assert store.saved_attempts() == [] + + asyncio.run(scenario()) + + +def test_a_worker_that_never_reports_a_result_is_uncertain_not_complete( + make_worker, +): + async def scenario(): + controller, store, _views = build(make_worker, {"no_result": True}) + await controller.start(OWNER, LEFT) + view = await finish(controller) + assert view.outcome.kind is OutcomeKind.UNCERTAIN + assert "uncertain" in view.notice + assert "completed" not in view.notice + assert store.saved_attempts() == [] + + asyncio.run(scenario()) + + +def test_a_worker_that_cannot_start_finishes_failed_and_frees_the_controller( + tmp_path, make_worker +): + async def scenario(): + controller, _store, _views = build(make_worker, {"exit_before_ready": True}) + await controller.start(OWNER, LEFT) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + assert controller.view().outcome.kind is not OutcomeKind.COMPLETED + assert controller.active is False + + asyncio.run(scenario()) + + +def test_an_invalid_start_leaves_the_controller_usable(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker) + with pytest.raises(proto.ProtocolError): + await controller.start(OWNER, ()) + assert controller.active is False + await controller.start(OWNER, LEFT) + await controller.aclose() + + asyncio.run(scenario()) + + +def test_owner_attempt_names_the_active_attempt_of_its_owner_only(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker, ONE_HOLD) + assert controller.owner_attempt(OWNER) is None + await controller.start(OWNER, LEFT) + await at_prompt(controller) + assert controller.owner_attempt(OWNER) is not None + assert controller.owner_attempt(OTHER) is None + await controller.aclose() + assert controller.owner_attempt(OWNER) is None + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("second_owner", [OWNER, OTHER]) +def test_a_late_disconnect_bound_to_an_earlier_attempt_cannot_cancel_a_later_one( + make_worker, commands_log, second_owner +): + async def scenario(): + controller, store, _views = build(make_worker, ONE_HOLD) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + earlier = controller.owner_attempt(OWNER) + await controller.cancel(OWNER) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + await controller.start(second_owner, LEFT) + await at_prompt(controller) + later = controller.owner_attempt(second_owner) + assert later is not None + assert later is not earlier + cancels = commands(commands_log).count("cancel") + # The late timer of the earlier attempt fires now. + await controller.client_disconnected(OWNER, attempt=earlier) + await asyncio.sleep(0.3) + assert controller.active is True + assert controller.view().stage is Stage.AWAITING_CONFIRMATION + assert commands(commands_log).count("cancel") == cancels + # Bound to the right attempt, the same call does cancel it. + await controller.client_disconnected(second_owner, attempt=later) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + assert controller.view().outcome.kind is OutcomeKind.CANCELLED + assert store.saved_attempts() == [] + + asyncio.run(scenario()) + + +def test_a_disconnect_bound_to_the_current_attempt_cancels_it_and_reaps_the_worker( + make_worker, +): + async def scenario(): + controller, _store, _views = build(make_worker, ONE_HOLD) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + await controller.client_disconnected( + OWNER, attempt=controller.owner_attempt(OWNER) + ) + await wait_for(lambda: controller.view().stage is Stage.FINISHED) + outcome = controller.view().outcome + assert outcome.kind is OutcomeKind.CANCELLED + assert outcome.exit_code == 0 + assert outcome.forced_termination is False + + asyncio.run(scenario()) + + +def test_a_bound_disconnect_by_a_non_owner_is_ignored(make_worker): + async def scenario(): + controller, _store, _views = build(make_worker, ONE_HOLD) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + await controller.client_disconnected( + OTHER, attempt=controller.owner_attempt(OWNER) + ) + await asyncio.sleep(0.3) + assert controller.active is True + await controller.aclose() + + asyncio.run(scenario()) + + +def test_a_bound_disconnect_for_a_finished_attempt_is_ignored(make_worker): + async def scenario(): + controller, store, _views = build(make_worker, ONE_HOLD) + await controller.start(OWNER, LEFT) + await at_prompt(controller) + earlier = controller.owner_attempt(OWNER) + await finish(controller) + await controller.client_disconnected(OWNER, attempt=earlier) + assert controller.view().outcome.kind is OutcomeKind.COMPLETED + assert len(store.saved_attempts()) == 1 + + asyncio.run(scenario()) + + +def test_the_station_stays_unavailable_after_a_result_that_reports_the_sdk_unreleased( + make_worker, +): + async def scenario(): + controller, _store, _views = build(make_worker, {"not_released": True}) + await controller.start(OWNER, LEFT) + view = await finish(controller) + assert view.outcome.kind is OutcomeKind.UNCERTAIN + assert "could not release the SDK" in view.outcome.message + assert view.outcome.files == () + assert not controller.active + # No new attempt may start while the gloves may still be in use. + with pytest.raises(AttemptError, match="could not release the SDK"): + await controller.start(OWNER, LEFT) + + asyncio.run(scenario()) diff --git a/tests/python/examples/manus_calibration/test_protocol.py b/tests/python/examples/manus_calibration/test_protocol.py new file mode 100644 index 0000000000..e0d736e7bf --- /dev/null +++ b/tests/python/examples/manus_calibration/test_protocol.py @@ -0,0 +1,517 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json + +import pytest +from isaaccapture_manus_calibration import protocol as proto + +SHA = "a" * 64 +ATTEMPT = "cal-abc" + + +def line(message: dict) -> str: + return json.dumps({"v": 1, **message}) + + +VALID = { + "ready": {"type": "ready", "worker_version": "1", "sdk_version": None}, + "status": { + "type": "status", + "attempt": ATTEMPT, + "phase": "discovering", + "gloves": [{"side": "left", "glove_id": 3}], + }, + "prompt": { + "type": "prompt", + "attempt": ATTEMPT, + "token": "t0", + "side": "left", + "glove_id": 3, + "index": 0, + "count": 2, + "title": "Flat", + "instruction": "Lay flat", + "mode": "hold", + "duration_s": 0, + "continuous": False, + "estimated_s": 0, + }, + "step_accepted": {"type": "step_accepted", "attempt": ATTEMPT, "token": "t0"}, + "step_progress": { + "type": "step_progress", + "attempt": ATTEMPT, + "token": "t0", + "elapsed_s": 0.5, + }, + "rejected": { + "type": "rejected", + "attempt": None, + "command": "confirm", + "reason": "stale_token", + "message": "no", + "token": "t9", + }, + "exported": { + "type": "exported", + "attempt": ATTEMPT, + "side": "right", + "glove_id": 4, + "file": "cal-abc_right.mcal", + "bytes": 10, + "sha256": SHA, + }, + "result": { + "type": "result", + "attempt": ATTEMPT, + "outcome": "completed", + "files": [{"side": "left", "file": "a.mcal", "bytes": 1, "sha256": SHA}], + "released": True, + }, +} + + +@pytest.mark.parametrize("name", sorted(VALID)) +def test_every_documented_event_parses(name): + event = proto.parse_event(line(VALID[name])) + assert type(event).__name__.lower().replace("_", "") == name.replace("_", "") + + +def test_failed_result_carries_an_error_and_cancelled_carries_neither(): + failed = proto.parse_event( + line( + { + "type": "result", + "attempt": None, + "outcome": "failed", + "error": { + "code": "no_gloves", + "message": "none", + "side": "left", + "step": 1, + }, + "released": True, + } + ) + ) + assert failed.error == proto.WorkerError("no_gloves", "none", proto.Side.LEFT, 1) + cancelled = proto.parse_event( + line( + { + "type": "result", + "attempt": ATTEMPT, + "outcome": "cancelled", + "released": True, + } + ) + ) + assert cancelled.files == () and cancelled.error is None + + +def mutate(name: str, **changes) -> str: + message = dict(VALID[name]) + for key, value in changes.items(): + if value is KeyError: + message.pop(key) + else: + message[key] = value + return line(message) + + +@pytest.mark.parametrize( + ("name", "changes"), + [ + ("prompt", {"extra": 1}), + ("prompt", {"title": KeyError}), + ("prompt", {"index": 2}), + ("prompt", {"count": 0}), + ("prompt", {"index": True}), + ("prompt", {"mode": "hold", "duration_s": 1.0}), + ("prompt", {"mode": "motion", "duration_s": 0}), + ("prompt", {"mode": "dance"}), + ("prompt", {"token": ""}), + ("prompt", {"side": "middle"}), + ("prompt", {"attempt": "has space"}), + ("step_progress", {"elapsed_s": -1}), + ("step_progress", {"elapsed_s": True}), + ("step_progress", {"elapsed_s": "1"}), + ("status", {"phase": "napping"}), + ("status", {"gloves": "left"}), + ( + "status", + { + "gloves": [ + {"side": "left", "glove_id": 1}, + {"side": "left", "glove_id": 2}, + ] + }, + ), + ("exported", {"file": "../escape.mcal"}), + ("exported", {"file": "/abs.mcal"}), + ("exported", {"file": "a/b.mcal"}), + ("exported", {"sha256": "ABC"}), + ("exported", {"bytes": -1}), + ("exported", {"attempt": None}), + ("rejected", {"reason": "because"}), + ("result", {"outcome": "completed", "files": KeyError}), + ("result", {"outcome": "cancelled"}), + ("result", {"outcome": "failed"}), + ("result", {"outcome": "failed", "error": {"code": "weird", "message": "x"}}), + ("ready", {"worker_version": 1}), + ("ready", {"v": 2}), + ], +) +def test_malformed_events_are_protocol_errors(name, changes): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate(name, **changes)) + + +FILE = {"side": "left", "file": "a.mcal", "bytes": 1, "sha256": SHA} +ERROR = {"code": "internal", "message": "x"} + + +@pytest.mark.parametrize( + ("name", "changes"), + [ + ("status", {"gloves": ["left"]}), + ("status", {"gloves": [{"side": "left", "glove_id": 1, "extra": 0}]}), + ("result", {"outcome": "completed", "files": "left"}), + ("result", {"outcome": "completed", "files": ["left"]}), + ("result", {"outcome": "completed", "files": [{**FILE, "extra": 1}]}), + ("result", {"outcome": "completed", "files": [{**FILE, "side": "middle"}]}), + ("result", {"outcome": "completed", "files": [{**FILE, "sha256": "short"}]}), + ("result", {"outcome": "completed", "error": ERROR}), + ("result", {"files": KeyError, "outcome": "cancelled", "error": ERROR}), + ("result", {"outcome": "cancelled", "files": []}), + ("result", {"outcome": "failed", "error": ERROR, "files": []}), + ("result", {"files": KeyError, "outcome": "failed", "error": "boom"}), + ( + "result", + { + "files": KeyError, + "outcome": "failed", + "error": {"code": "weird", "message": "x"}, + }, + ), + ("result", {"files": KeyError, "outcome": "failed", "error": {"message": "x"}}), + ( + "result", + {"files": KeyError, "outcome": "failed", "error": {**ERROR, "extra": 1}}, + ), + ( + "result", + {"files": KeyError, "outcome": "failed", "error": {**ERROR, "step": -1}}, + ), + ( + "result", + {"files": KeyError, "outcome": "failed", "error": {**ERROR, "step": True}}, + ), + ( + "result", + {"files": KeyError, "outcome": "failed", "error": {**ERROR, "step": 1.5}}, + ), + ( + "result", + { + "files": KeyError, + "outcome": "failed", + "error": {**ERROR, "side": "middle"}, + }, + ), + ("rejected", {"token": ""}), + ("rejected", {"token": 5}), + ("rejected", {"command": ""}), + ], +) +def test_more_malformed_events_are_protocol_errors(name, changes): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate(name, **changes)) + + +def test_a_completed_result_may_list_no_files_but_never_omit_the_field(): + event = proto.parse_event( + line( + { + "type": "result", + "attempt": ATTEMPT, + "outcome": "completed", + "files": [], + "released": True, + } + ) + ) + assert event.files == () + with pytest.raises(proto.ProtocolError): + proto.parse_event( + line( + { + "type": "result", + "attempt": ATTEMPT, + "outcome": "completed", + "released": True, + } + ) + ) + + +def test_every_documented_error_code_parses(): + for code in sorted(proto._ERROR_CODES): + event = proto.parse_event( + line( + { + "type": "result", + "attempt": ATTEMPT, + "outcome": "failed", + "error": {"code": code, "message": "m"}, + "released": True, + } + ) + ) + assert event.error.code == code + + +@pytest.mark.parametrize("released", [True, False]) +@pytest.mark.parametrize( + "extra", + [ + {"outcome": "completed", "files": []}, + {"outcome": "cancelled"}, + {"outcome": "failed", "error": {"code": "internal", "message": "m"}}, + ], +) +def test_released_parses_for_every_outcome(extra, released): + event = proto.parse_event( + line( + { + "type": "result", + "attempt": ATTEMPT, + "released": released, + **extra, + } + ) + ) + assert event.released is released + + +def test_a_result_without_released_is_a_protocol_error(): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("result", released=KeyError)) + + +@pytest.mark.parametrize("value", ["true", "false", 1, 0, None, [], {}, 1.0]) +def test_a_result_whose_released_is_not_a_boolean_is_a_protocol_error(value): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("result", released=value)) + + +def test_result_released_defaults_to_true_on_the_dataclass(): + result = proto.Result(attempt=None, outcome=proto.Outcome.CANCELLED) + assert result.released is True + + +@pytest.mark.parametrize( + "raw", + [ + "", + "not json", + "[]", + "null", + '{"v":1}', + '{"v":1,"type":"nope"}', + '{"v":"1","type":"ready"}', + '{"v":1,"type":"step_progress","attempt":"a","token":"t","elapsed_s":NaN}', + '{"v":1,"type":"step_progress","attempt":"a","token":"t","elapsed_s":Infinity}', + b"\xff\xfe", + b"x" * (proto.MAX_LINE_BYTES + 1), + ], +) +def test_garbage_lines_are_protocol_errors(raw): + with pytest.raises(proto.ProtocolError): + proto.parse_event(raw) + + +def test_commands_round_trip_and_order_sides_left_first(): + start = json.loads( + proto.encode_start("cal-1", (proto.Side.RIGHT, proto.Side.LEFT), "/stage") + ) + assert start == { + "v": 1, + "type": "start", + "attempt": "cal-1", + "sides": ["left", "right"], + "out_dir": "/stage", + } + assert json.loads(proto.encode_confirm("cal-1", "tok")) == { + "v": 1, + "type": "confirm", + "attempt": "cal-1", + "token": "tok", + } + assert json.loads(proto.encode_cancel("cal-1")) == { + "v": 1, + "type": "cancel", + "attempt": "cal-1", + } + assert proto.encode_cancel("cal-1").endswith(b"\n") + + +@pytest.mark.parametrize("sides", [(), (proto.Side.LEFT, proto.Side.LEFT)]) +def test_start_rejects_empty_or_duplicate_sides(sides): + with pytest.raises(proto.ProtocolError): + proto.encode_start("cal-1", sides, "/stage") + + +def test_start_rejects_a_bad_attempt_id(): + with pytest.raises(proto.ProtocolError): + proto.encode_start("no spaces", (proto.Side.LEFT,), "/stage") + + +# --- prompt: continuous steps ------------------------------------------------------------- + + +def test_a_prompt_with_an_empty_instruction_is_accepted(): + prompt = proto.parse_event(mutate("prompt", instruction="")) + assert prompt.instruction == "" + + +def test_the_pacing_interval_is_the_estimate_for_a_continuous_step_else_the_duration(): + assert ( + proto.parse_event(mutate("prompt", continuous=True, estimated_s=3.5)).pacing_s + == 3.5 + ) + motion = mutate( + "prompt", mode="motion", duration_s=0.4, continuous=False, estimated_s=0 + ) + assert proto.parse_event(motion).pacing_s == 0.4 + assert proto.parse_event(mutate("prompt")).pacing_s == 0 + + +def test_a_plain_hold_prompt_is_not_continuous(): + prompt = proto.parse_event(line(VALID["prompt"])) + assert prompt.continuous is False + assert prompt.estimated_s == 0.0 + assert prompt.mode is proto.StepMode.HOLD + + +def test_a_continuous_hold_prompt_carries_its_estimate(): + prompt = proto.parse_event(mutate("prompt", continuous=True, estimated_s=3.5)) + assert prompt.continuous is True + assert prompt.estimated_s == 3.5 + assert prompt.mode is proto.StepMode.HOLD + assert prompt.duration_s == 0.0 + + +def test_an_integer_estimate_is_accepted_and_read_as_a_float(): + prompt = proto.parse_event(mutate("prompt", continuous=True, estimated_s=3)) + assert prompt.estimated_s == 3.0 + assert isinstance(prompt.estimated_s, float) + + +def test_a_timed_motion_prompt_is_not_continuous_and_has_no_estimate(): + prompt = proto.parse_event( + mutate("prompt", mode="motion", duration_s=0.4, continuous=False, estimated_s=0) + ) + assert prompt.mode is proto.StepMode.MOTION + assert prompt.duration_s == 0.4 + assert prompt.continuous is False + assert prompt.estimated_s == 0.0 + + +def test_the_prompt_dataclass_defaults_to_a_non_continuous_step(): + prompt = proto.Prompt( + attempt=ATTEMPT, + token="t0", + side=proto.Side.LEFT, + glove_id=3, + index=0, + count=1, + title="t", + instruction="i", + mode=proto.StepMode.HOLD, + duration_s=0.0, + ) + assert prompt.continuous is False + assert prompt.estimated_s == 0.0 + with pytest.raises(AttributeError): + prompt.continuous = True # frozen + + +@pytest.mark.parametrize( + "changes", + [ + # continuous needs a positive estimate + {"continuous": True, "estimated_s": 0}, + {"continuous": True, "estimated_s": 0.0}, + # continuous is a hold, never a timed motion + {"continuous": True, "estimated_s": 3, "mode": "motion", "duration_s": 0.4}, + # a non-continuous step has no estimate, whatever its mode + {"continuous": False, "estimated_s": 3}, + {"continuous": False, "estimated_s": 0.001}, + {"continuous": False, "estimated_s": 2, "mode": "motion", "duration_s": 0.4}, + ], + ids=[ + "continuous-zero-int", + "continuous-zero-float", + "continuous-motion", + "plain-estimate", + "plain-tiny-estimate", + "motion-estimate", + ], +) +def test_inconsistent_continuous_and_estimate_pairs_are_protocol_errors(changes): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("prompt", **changes)) + + +@pytest.mark.parametrize("missing", ["continuous", "estimated_s"]) +def test_a_prompt_missing_a_continuous_field_is_a_protocol_error(missing): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("prompt", **{missing: KeyError})) + + +def test_a_prompt_missing_both_continuous_fields_is_a_protocol_error(): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("prompt", continuous=KeyError, estimated_s=KeyError)) + + +@pytest.mark.parametrize("value", [1, 0, "true", "false", None, [], {}, 1.0]) +def test_continuous_must_be_a_strict_boolean(value): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("prompt", continuous=value)) + + +@pytest.mark.parametrize("value", ["3", None, True, False, [], {}, [3]]) +def test_estimated_s_must_be_a_number(value): + with pytest.raises(proto.ProtocolError): + proto.parse_event(mutate("prompt", continuous=True, estimated_s=value)) + + +@pytest.mark.parametrize( + "value", + [-1, -0.001, float("nan"), float("inf"), float("-inf")], + ids=["minus-one", "minus-tiny", "nan", "inf", "minus-inf"], +) +def test_estimated_s_must_be_finite_and_not_negative(value): + for continuous in (True, False): + with pytest.raises(proto.ProtocolError): + proto.parse_event( + mutate("prompt", continuous=continuous, estimated_s=value) + ) + + +def test_an_estimate_beyond_the_float_range_is_a_protocol_error(): + text = mutate("prompt", continuous=True, estimated_s=1).replace( + '"estimated_s": 1', '"estimated_s": 1e400' + ) + assert "1e400" in text + with pytest.raises(proto.ProtocolError): + proto.parse_event(text) + + +def test_an_extra_key_on_a_continuous_prompt_is_still_a_protocol_error(): + with pytest.raises(proto.ProtocolError): + proto.parse_event( + mutate("prompt", continuous=True, estimated_s=3, extra_field=1) + ) diff --git a/tests/python/examples/manus_calibration/test_server.py b/tests/python/examples/manus_calibration/test_server.py new file mode 100644 index 0000000000..f8f69296ea --- /dev/null +++ b/tests/python/examples/manus_calibration/test_server.py @@ -0,0 +1,1507 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The page server over real sockets and a real fake-worker subprocess.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from conftest import commands +from isaaccapture_examples.manus_calibration import server as server_module +from isaaccapture_manus_calibration.worker import OutcomeKind +from web_helpers import ( + CLIENT_A, + CLIENT_B, + Sse, + api, + raw_request, + request, + run_flow, + serving, +) + +HOLD = { + "steps": [ + {"title": "A", "instruction": "a", "time": 0.0}, + {"title": "B", "instruction": "b", "time": 0.0}, + ] +} +# One hold step: the worker then waits for a confirmation, which keeps an attempt open. +WAITING = {"steps": [{"title": "A", "instruction": "a", "time": 0.0}]} +# A long motion step keeps the worker sampling after one confirmation. +SAMPLING = {"steps": [{"title": "W", "instruction": "w", "time": 3.0}]} +HEADERS_ON_EVERY_RESPONSE = { + "content-security-policy": ( + "default-src 'none'; script-src 'self'; style-src 'self'; " + "connect-src 'self'; base-uri 'none'; form-action 'none'; " + "frame-ancestors 'none'" + ), + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "cache-control": "no-store", +} + + +def test_static_assets_are_served_with_their_content_types(make_worker): + async def scenario(): + async with serving(make_worker) as (server, _store): + expected = { + "/": "text/html; charset=utf-8", + "/app.js": "text/javascript; charset=utf-8", + "/style.css": "text/css; charset=utf-8", + } + for path, content_type in expected.items(): + reply = await request(server.port, "GET", path) + assert reply.status == 200, path + assert reply.headers["content-type"] == content_type + assert int(reply.headers["content-length"]) == len(reply.body) > 0 + page = (await request(server.port, "GET", "/")).body.decode() + assert "Manus glove calibration" in page + assert '