diff --git a/.gitignore b/.gitignore index cf572b0..aa66677 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,13 @@ # production /build +# Python sidecar +__pycache__/ +*.py[cod] +*.egg-info/ +/sidecar/build/ +/sidecar/dist/ + # misc .DS_Store *.pem diff --git a/docs/adr/0005-freemocap-sidecar-contract.md b/docs/adr/0005-freemocap-sidecar-contract.md index 946664b..6e196ac 100644 --- a/docs/adr/0005-freemocap-sidecar-contract.md +++ b/docs/adr/0005-freemocap-sidecar-contract.md @@ -74,7 +74,7 @@ The sidecar is a local Python process exposing: - `POST http://localhost:8765/session/start` — arms capture, returns `{ "sessionId": "", "calibrationId": "" }` - `POST http://localhost:8765/session/stop` — flushes, closes stream -No Docker is required. Users install the Python sidecar via `pip install rowing-tracker-sidecar` (separate PyPI package). The app polls the health endpoint during the readiness gate. +No Docker is required. The sidecar is a repo-owned Python package installed locally with `python -m pip install -e sidecar` unless a public package release is explicitly published. The app polls the health endpoint during the readiness gate. Port 8765 is the default; configurable in `UserSettings.sidecarPort`. diff --git a/docs/sidecar-local-setup.md b/docs/sidecar-local-setup.md index a229b4b..ba9731a 100644 --- a/docs/sidecar-local-setup.md +++ b/docs/sidecar-local-setup.md @@ -1,143 +1,175 @@ -# Running the freemocap sidecar locally +# Running the FreeMoCap sidecar locally -This guide covers the freemocap sidecar integration for local development and testing. +This guide covers the Rowing Tracker sidecar used by **Multi-camera sidecar** +capture. The sidecar is a local Python process that exposes the ADR-0005 +HTTP/WebSocket contract on localhost. + +The package is owned by this repository. It is not currently published on +public PyPI, so do not install it with `pip install rowing-tracker-sidecar` +unless a release note says public publishing has happened. ## Prerequisites - Python 3.10+ with `venv` -- The app running locally (`npm run dev`) - -## Option A — real freemocap sidecar +- The app running locally with `npm run dev` -Install the sidecar from the distribution available to your environment: +## Install from this repository ```bash -python3 -m venv .venv -source .venv/bin/activate +python3 -m venv .venv-sidecar +source .venv-sidecar/bin/activate python -m pip install --upgrade pip -python -m pip install rowing-tracker-sidecar -rowing-tracker-sidecar --port 8765 +python -m pip install -e sidecar ``` -If the sidecar package is not available in your environment, use Option B for local app development. - -The sidecar exposes: -- `ws://localhost:8765/pose-stream` — streams `KeypointFrame` JSON -- `GET http://localhost:8765/health` — returns `{ status, fps, cameras, schemaVersion }` -- `POST http://localhost:8765/session/start` — arms capture -- `POST http://localhost:8765/session/stop` — flushes and closes - -## Option B — minimal mock server (for UI/API dev without hardware) - -```python -#!/usr/bin/env python3 -"""Minimal sidecar mock — runs without freemocap or cameras.""" -import asyncio, json, math, random, time -import websockets -from http.server import BaseHTTPRequestHandler, HTTPServer -import threading - -PORT = 8765 -FPS = 30 - -def health(): - return {"status": "ready", "fps": FPS, "cameras": 3, "schemaVersion": 2} - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/health": - body = json.dumps(health()).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(body) - def do_POST(self): - body = json.dumps({"sessionId": "mock-session", "calibrationId": "mock-calib"}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(body) - def log_message(self, *a): pass - -async def pose_stream(websocket): - frame_index = 0 - while True: - ts = time.time() * 1000 - keypoints = [ - {"index": i, "x": 50 + math.sin(i * 0.5) * 200, - "y": 500 + math.cos(i * 0.3 + frame_index * 0.05) * 300, - "z": 1000 + random.gauss(0, 20), - "confidence": 0.85 + random.gauss(0, 0.05)} - for i in range(33) - ] - frame = {"frameIndex": frame_index, "timestampMs": ts, - "keypoints": keypoints, - "quality": {"trackedCount": 33, "meanConfidence": 0.85, - "reprojectionErrorMm": 1.2, "cameraCount": 3}} - try: - await websocket.send(json.dumps(frame)) - except websockets.exceptions.ConnectionClosed: - break - frame_index += 1 - await asyncio.sleep(1 / FPS) - -async def main(): - http = HTTPServer(("", PORT), Handler) - threading.Thread(target=http.serve_forever, daemon=True).start() - print(f"Sidecar mock running on port {PORT}") - async with websockets.serve(pose_stream, "localhost", PORT, path="/pose-stream"): - await asyncio.Future() - -asyncio.run(main()) +Verify the command is available: + +```bash +rowing-tracker-sidecar --help ``` -Save as `scripts/sidecar-mock.py` and run: +## Synthetic mode + +Synthetic mode runs without cameras or FreeMoCap. Use it for local UI/API +development and CI-style contract checks. ```bash -python3 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install websockets -python scripts/sidecar-mock.py +rowing-tracker-sidecar --source synthetic --port 8765 +``` + +Expected health response: + +```bash +curl http://localhost:8765/health +``` + +```json +{ + "status": "ready", + "fps": 30.0, + "cameras": 3, + "schemaVersion": 2, + "source": "synthetic", + "calibrationId": "synthetic-calibration" +} +``` + +## FreeMoCap recorded-data mode + +Recorded-data mode streams FreeMoCap-style 3D output through the same live +sidecar contract. This is useful for validating the Rowing Tracker integration +against real `world-mm-3d` coordinates before a live camera runtime is wired in. + +```bash +rowing-tracker-sidecar \ + --source freemocap \ + --freemocap-data /path/to/freemocap/output/mediapipe_body_3d_xyz.npy \ + --camera-count 3 \ + --fps 30 \ + --port 8765 ``` +Supported input formats: + +- `.json` or `.jsonl` containing ADR-0005-style keypoint frames or raw + `(frames, 33, 4)` arrays +- `.npy` containing `(frames, 33, 4)` FreeMoCap body keypoints; this requires + `numpy` in the sidecar environment +- a directory containing a known FreeMoCap output file such as + `mediapipe_body_3d_xyz.npy` + +If you select `--source freemocap` without `--freemocap-data`, health reports +`status: "error"` with diagnostics. That failure is intentional: the current +repo-owned sidecar has a stable adapter boundary for FreeMoCap data, while live +camera capture depends on the FreeMoCap runtime available in the user's +environment. + ## Using the sidecar in the app -1. Start the sidecar (real or mock) on port 8765. -2. Open the app at `http://localhost:3000/mocap`. -3. Check **Multi-camera sidecar** — the UI polls health and shows "Sidecar ready — 3 cameras, 30 fps". -4. Click **Start sidecar capture** — the app creates a session with `source=sidecar`, `capturePerspective=sidecar-3d`, and a v2 `PoseFrameStream`. -5. The session detail page opens as normal. Sidecar-3D-only posture faults appear with `severity=pending` until tuned thresholds are defined. +1. Start the sidecar on port `8765`. +2. Start Rowing Tracker with `npm run dev`. +3. Open `http://localhost:3000/mocap`. +4. Check **Multi-camera sidecar**. +5. Wait for `Sidecar ready - 3 cameras, 30 fps`. +6. Click **Start sidecar capture**. +7. Row the session. +8. Click **Stop** and open **View replay**. + +Sidecar capture skips browser catch/finish calibration. Calibration traceability +comes from the sidecar's `calibrationId`. -## API endpoints +## Local sidecar contract | Method | Path | Purpose | |--------|------|---------| -| `POST` | `/api/mocap/sessions/:id/sidecar/connect` | Verify sidecar health and arm capture | -| `GET` | `/api/mocap/sessions/:id/sidecar/status` | Proxy to `localhost:8765/health` | -| `POST` | `/api/mocap/sessions/:id/sidecar/stop` | Stop the sidecar session and flush the stream | - -All routes require an authenticated session and an owned `MocapSession`. `connect` also requires the session to be in `capturing` status and the sidecar schema to match `keypointSchemaVersion = 2`. +| `GET` | `/health` | Report readiness, fps, camera count, schema version, source, calibration id, and diagnostics | +| `POST` | `/session/start` | Arm capture and return `{ sessionId, calibrationId }` | +| `POST` | `/session/stop` | Stop capture and flush/close the stream | +| `WS` | `/pose-stream` | Stream one schema-v2 `sidecar-3d` keypoint frame per message | -## PoseFrameStream v2 blob format +The app also proxies lifecycle calls through: -v2 blobs are written when `source=sidecar`. Key differences from v1: - -- `keypointSchemaVersion = 2` in header -- `coordinateSpace = "world-mm-3d"` -- Each keypoint is `[x, y, z, confidence]` (4 × Float32 per keypoint, vs 3 × Float32 in v1) -- Header byte 20: `coordinateSpace` (0 = normalized-2d, 1 = world-mm-3d) -- Header byte 21: `cameraCount` -- `calibrationId` is stored on `MocapSession` for traceability -- v1 blobs are unchanged and remain readable +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/api/mocap/sessions/:id/sidecar/connect` | Verify sidecar health and arm capture | +| `GET` | `/api/mocap/sessions/:id/sidecar/status` | Proxy sidecar health | +| `POST` | `/api/mocap/sessions/:id/sidecar/stop` | Stop the sidecar session | + +All app routes require an authenticated session and an owned `MocapSession`. +`connect` also requires the app-side session to be in `capturing` status and the +sidecar schema to match `keypointSchemaVersion = 2`. + +## Troubleshooting + +- **`pip install rowing-tracker-sidecar` cannot find a package**: install from + this repository with `python -m pip install -e sidecar`. +- **`Sidecar not reachable on port 8765`**: start the sidecar, check the port, + and confirm `curl http://localhost:8765/health` works. +- **Wrong port**: run the sidecar with `--port ` and configure + `UserSettings.sidecarPort` to the same value. +- **`status: "error"` with FreeMoCap diagnostics**: the FreeMoCap source is not + configured or the data path cannot be read. Pass `--freemocap-data`. +- **Incompatible schema**: Rowing Tracker expects schema version `2`. +- **Missing cameras or calibration**: health should remain `initializing` or + `error`; do not start capture until it is `ready`. +- **Low or zero fps**: reduce camera load, check USB bandwidth, and verify the + source reports a stable fps before recording. +- **Stream errors during capture**: stop the app capture, stop the sidecar, and + restart both. The sidecar logs include session start/stop and WebSocket + connect/disconnect events. + +## Hardware-gated smoke test + +Use this manual path when a real camera rig and FreeMoCap output are available: + +1. Capture or locate a FreeMoCap `(frames, 33, 4)` output file. +2. Start the sidecar in recorded-data mode with that file. +3. Confirm `/health` is `ready`, `schemaVersion` is `2`, and `cameras` matches + the rig. +4. Record a Rowing Tracker sidecar session from `/mocap`. +5. Stop capture and open the replay. +6. Confirm the stored session uses `source=sidecar`, + `capturePerspective=sidecar-3d`, has pose frames, and runs post-session + analysis. + +This smoke test is hardware/data-gated and should not block normal CI. + +## Privacy and licensing + +The sidecar binds to `127.0.0.1` by default and makes no cloud calls. Raw video, +raw keypoints, and reconstructed body geometry stay local unless the user later +chooses separate Rowing Tracker sharing settings. + +FreeMoCap is AGPL-licensed. Keep it as a separate local process and document the +dependency boundary before distributing bundled artifacts. ## Tests ```bash +npx tsx --test tests/sidecarCliContract.test.ts +npx tsx --test tests/sidecarPackageInstall.test.ts npx tsx --test tests/sidecarTracer.test.ts npx tsx --test tests/freemocapSidecarSource.test.ts npx tsx --test tests/sidecarMockContract.test.ts npm run test:e2e -- tests/e2e/mocap-capture.spec.ts ``` - -`tests/sidecarMockContract.test.ts` uses the in-process `tests/helpers/testSidecarMock.ts` fixture. It installs ADR-0005-compatible health, session/start, session/stop, and pose-stream behavior, persists uploaded v2 bytes to `LocalDiskStorage`, finalizes the blob, and runs post-session analysis without a real freemocap install or camera rig. diff --git a/sidecar/README.md b/sidecar/README.md new file mode 100644 index 0000000..d0e8f7d --- /dev/null +++ b/sidecar/README.md @@ -0,0 +1,44 @@ +# Rowing Tracker Sidecar + +Local sidecar service for Rowing Tracker multi-camera mocap capture. It exposes +the ADR-0005 localhost contract expected by the app and streams schema-v2 +`sidecar-3d` pose frames. + +Install from this repository: + +```bash +python3 -m venv .venv-sidecar +source .venv-sidecar/bin/activate +python -m pip install -e sidecar +``` + +Run the deterministic hardware-free source: + +```bash +rowing-tracker-sidecar --source synthetic --port 8765 +``` + +Run recorded FreeMoCap-style output through the same live contract: + +```bash +rowing-tracker-sidecar \ + --source freemocap \ + --freemocap-data /path/to/mediapipe_body_3d_xyz.npy \ + --camera-count 3 \ + --fps 30 \ + --port 8765 +``` + +The service binds to `127.0.0.1` by default and exposes: + +- `GET /health` +- `POST /session/start` +- `POST /session/stop` +- `ws://localhost:/pose-stream` + +Synthetic mode is hardware-free and intended for development and CI. FreeMoCap +recorded-data mode supports JSON, JSONL, and NPY `(frames, 33, 4)` sources. +Selecting `--source freemocap` without `--freemocap-data` fails readiness with a +clear diagnostic instead of pretending the camera rig is ready. + +The sidecar makes no cloud calls and is local-only by default. diff --git a/sidecar/pyproject.toml b/sidecar/pyproject.toml new file mode 100644 index 0000000..b08629f --- /dev/null +++ b/sidecar/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "rowing-tracker-sidecar" +version = "0.1.0" +description = "Local FreeMoCap-compatible sidecar service for Rowing Tracker mocap capture" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Rowing Tracker" }] +dependencies = [] + +[project.optional-dependencies] +freemocap = [ + "freemocap>=1.8,<2", + "numpy>=1.24", +] + +[project.scripts] +rowing-tracker-sidecar = "rowing_tracker_sidecar.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/sidecar/src/rowing_tracker_sidecar/__init__.py b/sidecar/src/rowing_tracker_sidecar/__init__.py new file mode 100644 index 0000000..612112a --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/__init__.py @@ -0,0 +1,3 @@ +"""Rowing Tracker local mocap sidecar.""" + +__version__ = "0.1.0" diff --git a/sidecar/src/rowing_tracker_sidecar/__main__.py b/sidecar/src/rowing_tracker_sidecar/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sidecar/src/rowing_tracker_sidecar/cli.py b/sidecar/src/rowing_tracker_sidecar/cli.py new file mode 100644 index 0000000..fdeb988 --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/cli.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import argparse +import logging + +from .server import SidecarServer +from .sources import build_frame_source + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="rowing-tracker-sidecar", + description="Run the Rowing Tracker local mocap sidecar.", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument( + "--source", + choices=["synthetic", "freemocap"], + default="synthetic", + help="Frame source. synthetic is deterministic and hardware-free.", + ) + parser.add_argument("--fps", type=float, default=30.0) + parser.add_argument("--camera-count", type=int, default=3) + parser.add_argument("--calibration-id") + parser.add_argument( + "--freemocap-data", + help="FreeMoCap-style JSON/JSONL/NPY data file or output directory.", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + source = build_frame_source( + source=args.source, + fps=args.fps, + camera_count=args.camera_count, + calibration_id=args.calibration_id, + freemocap_data=args.freemocap_data, + ) + server = SidecarServer(args.host, args.port, source) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + return 0 diff --git a/sidecar/src/rowing_tracker_sidecar/contract.py b/sidecar/src/rowing_tracker_sidecar/contract.py new file mode 100644 index 0000000..727f6b7 --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/contract.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, TypedDict + +KEYPOINT_SCHEMA_VERSION = 2 +KEYPOINT_COUNT = 33 + + +class Keypoint(TypedDict): + index: int + x: float + y: float + z: float + confidence: float + + +class FrameQuality(TypedDict): + tracked_count: int + mean_confidence: float + reprojection_error_mm: float + camera_count: int + + +class SidecarFrame(TypedDict): + schema_version: int + frame_index: int + timestamp_ms: int + source: Literal["sidecar-3d"] + keypoints: list[Keypoint] + quality: FrameQuality + + +@dataclass(frozen=True) +class SourceHealth: + status: Literal["ready", "initializing", "error"] + fps: float + camera_count: int + schema_version: int = KEYPOINT_SCHEMA_VERSION + source: str = "synthetic" + calibration_id: str | None = None + diagnostics: tuple[str, ...] = () + + def to_json(self) -> dict[str, object]: + return { + "status": self.status, + "fps": self.fps, + "cameras": self.camera_count, + "schemaVersion": self.schema_version, + "source": self.source, + "calibrationId": self.calibration_id, + "diagnostics": list(self.diagnostics), + } diff --git a/sidecar/src/rowing_tracker_sidecar/server.py b/sidecar/src/rowing_tracker_sidecar/server.py new file mode 100644 index 0000000..cf9a7c0 --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/server.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import struct +import threading +import time +import uuid +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +from .contract import KEYPOINT_SCHEMA_VERSION, SourceHealth +from .sources import FrameSource + +LOGGER = logging.getLogger(__name__) +WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +class SidecarState: + def __init__(self, source: FrameSource) -> None: + self.source = source + self.lock = threading.RLock() + self.active = False + self.session_id: str | None = None + + def health(self) -> SourceHealth: + return self.source.health() + + def start(self) -> tuple[int, dict[str, object]]: + with self.lock: + health = self.health() + if health.status != "ready": + return ( + HTTPStatus.CONFLICT, + { + "status": health.status, + "schemaVersion": health.schema_version, + "diagnostics": list(health.diagnostics), + }, + ) + if health.schema_version != KEYPOINT_SCHEMA_VERSION: + return ( + HTTPStatus.CONFLICT, + { + "status": "incompatible-schema", + "schemaVersion": health.schema_version, + "expectedSchemaVersion": KEYPOINT_SCHEMA_VERSION, + }, + ) + self.session_id = str(uuid.uuid4()) + self.active = True + LOGGER.info("sidecar session started: %s", self.session_id) + return ( + HTTPStatus.OK, + { + "sessionId": self.session_id, + "calibrationId": self.source.calibration_id, + }, + ) + + def stop(self) -> dict[str, object]: + with self.lock: + stopped_id = self.session_id + self.active = False + self.session_id = None + LOGGER.info("sidecar session stopped: %s", stopped_id) + return {"status": "stopped", "sessionId": stopped_id} + + def is_active(self) -> bool: + with self.lock: + return self.active + + +class _ThreadingHTTPServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +class SidecarServer: + def __init__(self, host: str, port: int, source: FrameSource) -> None: + self.state = SidecarState(source) + self._shutdown = threading.Event() + + state = self.state + shutdown_event = self._shutdown + + class Handler(SidecarRequestHandler): + sidecar_state = state + shutdown_event_ref = shutdown_event + + self._server = _ThreadingHTTPServer((host, port), Handler) + self.host = host + self.port = port + + def serve_forever(self) -> None: + LOGGER.info("rowing-tracker-sidecar listening on http://%s:%s", self.host, self.port) + self._server.serve_forever(poll_interval=0.2) + + def shutdown(self) -> None: + self._shutdown.set() + self._server.shutdown() + self._server.server_close() + + +class SidecarRequestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "RowingTrackerSidecar/0.1" + sidecar_state: SidecarState + shutdown_event_ref: threading.Event + + def do_OPTIONS(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self._send_cors_headers() + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self) -> None: + if self.path == "/health": + self._send_json(HTTPStatus.OK, self.sidecar_state.health().to_json()) + return + if self.path == "/pose-stream": + self._handle_pose_stream() + return + self._send_json(HTTPStatus.NOT_FOUND, {"error": f"Unknown path: {self.path}"}) + + def do_POST(self) -> None: + self._drain_request_body() + if self.path == "/session/start": + status, body = self.sidecar_state.start() + self._send_json(status, body) + return + if self.path == "/session/stop": + body = self.sidecar_state.stop() + self._send_json(HTTPStatus.OK, body) + return + self._send_json(HTTPStatus.NOT_FOUND, {"error": f"Unknown path: {self.path}"}) + + def log_message(self, format: str, *args: Any) -> None: + LOGGER.debug("%s - %s", self.address_string(), format % args) + + def _drain_request_body(self) -> None: + length = int(self.headers.get("Content-Length") or "0") + if length: + self.rfile.read(length) + + def _send_json(self, status: int, body: dict[str, object]) -> None: + payload = json.dumps(body, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _send_cors_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def _handle_pose_stream(self) -> None: + key = self.headers.get("Sec-WebSocket-Key") + if self.headers.get("Upgrade", "").lower() != "websocket" or not key: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Expected WebSocket upgrade"}) + return + + accept = base64.b64encode( + hashlib.sha1((key + WEBSOCKET_GUID).encode("ascii")).digest(), + ).decode("ascii") + self.send_response_only(HTTPStatus.SWITCHING_PROTOCOLS) + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept) + self.end_headers() + + LOGGER.info("pose-stream websocket connected") + frame_index = 0 + frame_interval = 1.0 / max(1.0, float(self.sidecar_state.source.fps)) + try: + while not self.shutdown_event_ref.is_set(): + if not self.sidecar_state.is_active(): + time.sleep(0.05) + continue + timestamp_ms = int(time.time() * 1000) + frame = self.sidecar_state.source.frame(frame_index, timestamp_ms) + _send_websocket_text(self.request, json.dumps(frame, separators=(",", ":"))) + frame_index += 1 + time.sleep(frame_interval) + except OSError: + LOGGER.info("pose-stream websocket disconnected") + + +def _send_websocket_text(sock: Any, message: str) -> None: + payload = message.encode("utf-8") + length = len(payload) + if length <= 125: + header = struct.pack("!BB", 0x81, length) + elif length <= 65535: + header = struct.pack("!BBH", 0x81, 126, length) + else: + header = struct.pack("!BBQ", 0x81, 127, length) + sock.sendall(header + payload) diff --git a/sidecar/src/rowing_tracker_sidecar/sources.py b/sidecar/src/rowing_tracker_sidecar/sources.py new file mode 100644 index 0000000..c3c4e55 --- /dev/null +++ b/sidecar/src/rowing_tracker_sidecar/sources.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import itertools +import json +import math +import os +import pathlib +from typing import Protocol + +from .contract import KEYPOINT_COUNT, KEYPOINT_SCHEMA_VERSION, SidecarFrame, SourceHealth + + +class FrameSource(Protocol): + @property + def fps(self) -> float: ... + + @property + def camera_count(self) -> int: ... + + @property + def calibration_id(self) -> str | None: ... + + def health(self) -> SourceHealth: ... + + def frame(self, frame_index: int, timestamp_ms: int) -> SidecarFrame: ... + + +def build_frame_source( + *, + source: str, + fps: float, + camera_count: int, + calibration_id: str | None, + freemocap_data: str | None, +) -> FrameSource: + if source == "synthetic": + return SyntheticFrameSource( + fps=fps, + camera_count=camera_count, + calibration_id=calibration_id or "synthetic-calibration", + ) + if source == "freemocap": + return FreeMocapRecordedSource( + data_path=freemocap_data, + fps=fps, + camera_count=camera_count, + calibration_id=calibration_id, + ) + raise ValueError(f"Unsupported sidecar source: {source}") + + +class SyntheticFrameSource: + source_name = "synthetic" + + def __init__( + self, + *, + fps: float = 30.0, + camera_count: int = 3, + calibration_id: str = "synthetic-calibration", + ) -> None: + self._fps = fps + self._camera_count = camera_count + self._calibration_id = calibration_id + + @property + def fps(self) -> float: + return self._fps + + @property + def camera_count(self) -> int: + return self._camera_count + + @property + def calibration_id(self) -> str: + return self._calibration_id + + def health(self) -> SourceHealth: + invalid = _invalid_runtime_diagnostics(self.fps, self.camera_count) + if invalid: + return SourceHealth( + status="error", + fps=self.fps, + camera_count=max(0, self.camera_count), + source=self.source_name, + calibration_id=self.calibration_id, + diagnostics=invalid, + ) + return SourceHealth( + status="ready", + fps=self.fps, + camera_count=self.camera_count, + source=self.source_name, + calibration_id=self.calibration_id, + diagnostics=("synthetic source ready",), + ) + + def frame(self, frame_index: int, timestamp_ms: int) -> SidecarFrame: + hip_knee_depth = _stroke_depth(frame_index) + keypoints = [_untracked_point(index) for index in range(KEYPOINT_COUNT)] + + keypoints[11] = _point(11, -180, 700, 160) + keypoints[12] = _point(12, 180, 700, 160) + keypoints[13] = _point(13, -210, 850, 180) + keypoints[14] = _point(14, 210, 850, 180) + keypoints[15] = _point(15, -220, 1000, 210) + keypoints[16] = _point(16, 220, 1000, 210) + keypoints[23] = _point(23, -120, 1000, 0) + keypoints[24] = _point(24, 120, 1000, 0) + keypoints[25] = _point(25, -120, 1000, hip_knee_depth) + keypoints[26] = _point(26, 120, 1000, hip_knee_depth) + keypoints[27] = _point(27, -120, 1100, hip_knee_depth + 0.1) + keypoints[28] = _point(28, 120, 1100, hip_knee_depth + 0.1) + + # Keep every frame deterministic while still giving the app changing + # 3D coordinates to encode and analyze. + phase = frame_index * 0.05 + for kp in keypoints: + if kp["confidence"] > 0: + kp["y"] = float(kp["y"] + math.sin(phase) * 8) + + return { + "schema_version": KEYPOINT_SCHEMA_VERSION, + "frame_index": frame_index, + "timestamp_ms": timestamp_ms, + "source": "sidecar-3d", + "keypoints": keypoints, + "quality": { + "tracked_count": 13, + "mean_confidence": 0.92, + "reprojection_error_mm": 1.2, + "camera_count": self.camera_count, + }, + } + + +class FreeMocapRecordedSource: + source_name = "freemocap" + + def __init__( + self, + *, + data_path: str | None, + fps: float, + camera_count: int, + calibration_id: str | None, + ) -> None: + self._fps = fps + self._camera_count = camera_count + self._data_path = pathlib.Path(data_path).expanduser() if data_path else None + self._frames: list[list[list[float]]] = [] + self._diagnostics: tuple[str, ...] = () + self._calibration_id = calibration_id + self._load() + + @property + def fps(self) -> float: + return self._fps + + @property + def camera_count(self) -> int: + return self._camera_count + + @property + def calibration_id(self) -> str | None: + return self._calibration_id + + def health(self) -> SourceHealth: + invalid = _invalid_runtime_diagnostics(self.fps, self.camera_count) + if invalid: + return SourceHealth( + status="error", + fps=self.fps, + camera_count=max(0, self.camera_count), + source=self.source_name, + calibration_id=self.calibration_id, + diagnostics=invalid, + ) + if not self._frames: + return SourceHealth( + status="error", + fps=self.fps, + camera_count=0, + source=self.source_name, + calibration_id=self.calibration_id, + diagnostics=self._diagnostics + or ( + "FreeMoCap source requires --freemocap-data with JSON, JSONL, or NPY output", + ), + ) + return SourceHealth( + status="ready", + fps=self.fps, + camera_count=self.camera_count, + source=self.source_name, + calibration_id=self.calibration_id, + diagnostics=self._diagnostics, + ) + + def frame(self, frame_index: int, timestamp_ms: int) -> SidecarFrame: + if not self._frames: + raise RuntimeError("FreeMoCap source is not ready") + raw = self._frames[frame_index % len(self._frames)] + keypoints = [] + confidences = [] + for index, values in enumerate(raw): + x, y, z, confidence = values + confidences.append(confidence) + keypoints.append( + { + "index": index, + "x": float(x), + "y": float(y), + "z": float(z), + "confidence": float(confidence), + }, + ) + tracked = sum(1 for confidence in confidences if confidence >= 0.5) + mean_confidence = sum(confidences) / len(confidences) + return { + "schema_version": KEYPOINT_SCHEMA_VERSION, + "frame_index": frame_index, + "timestamp_ms": timestamp_ms, + "source": "sidecar-3d", + "keypoints": keypoints, + "quality": { + "tracked_count": tracked, + "mean_confidence": mean_confidence, + "reprojection_error_mm": 0.0, + "camera_count": self.camera_count, + }, + } + + def _load(self) -> None: + if self._data_path is None: + self._diagnostics = ( + "FreeMoCap live camera runtime is not configured in this build", + "pass --freemocap-data to stream recorded FreeMoCap output through the ADR-0005 contract", + ) + return + path = _resolve_freemocap_path(self._data_path) + if path is None or not path.exists(): + self._diagnostics = (f"FreeMoCap data path not found: {self._data_path}",) + return + try: + if path.suffix.lower() in {".json", ".jsonl"}: + frames = _load_json_frames(path) + elif path.suffix.lower() == ".npy": + frames = _load_npy_frames(path) + else: + raise ValueError(f"Unsupported FreeMoCap data file: {path}") + _validate_recorded_frames(frames) + self._frames = frames + self._calibration_id = self._calibration_id or f"freemocap-{path.stem}" + self._diagnostics = (f"loaded FreeMoCap data from {path}",) + except Exception as exc: + self._frames = [] + self._diagnostics = (f"Failed to load FreeMoCap data: {exc}",) + + +def _resolve_freemocap_path(path: pathlib.Path) -> pathlib.Path | None: + if path.is_file(): + return path + if not path.is_dir(): + return path + candidates = [ + "mediapipe_body_3d_xyz.npy", + "mediapipe_body_3d_xyz_confidence.npy", + "body_3d_xyz.npy", + "sidecar_frames.jsonl", + "sidecar_frames.json", + ] + for relative in candidates: + candidate = path / relative + if candidate.exists(): + return candidate + for candidate in itertools.chain(path.glob("*.jsonl"), path.glob("*.json"), path.glob("*.npy")): + return candidate + return None + + +def _load_json_frames(path: pathlib.Path) -> list[list[list[float]]]: + if path.suffix.lower() == ".jsonl": + frames = [] + for line in path.read_text().splitlines(): + if not line.strip(): + continue + frames.append(_extract_frame_values(json.loads(line))) + return frames + body = json.loads(path.read_text()) + if isinstance(body, dict) and "frames" in body: + body = body["frames"] + if not isinstance(body, list): + raise ValueError("JSON FreeMoCap data must be a list or {frames: [...]}") + return [_extract_frame_values(frame) for frame in body] + + +def _extract_frame_values(frame: object) -> list[list[float]]: + if isinstance(frame, dict) and "keypoints" in frame: + keypoints = frame["keypoints"] + if not isinstance(keypoints, list): + raise ValueError("frame keypoints must be a list") + ordered = sorted(keypoints, key=lambda item: int(item["index"])) + return [ + [ + float(item["x"]), + float(item["y"]), + float(item["z"]), + float(item.get("confidence", 1.0)), + ] + for item in ordered + ] + if isinstance(frame, list): + return [[float(value) for value in point] for point in frame] + raise ValueError("frame must be a keypoint frame object or a 33x4 array") + + +def _load_npy_frames(path: pathlib.Path) -> list[list[list[float]]]: + try: + import numpy as np # type: ignore + except Exception as exc: + raise ValueError("reading .npy FreeMoCap data requires numpy") from exc + array = np.load(os.fspath(path)) + if array.ndim != 3: + raise ValueError(f"expected NPY array with shape (frames, 33, 4), got {array.shape}") + if array.shape[1] != KEYPOINT_COUNT or array.shape[2] < 3: + raise ValueError(f"expected NPY array with shape (frames, 33, 4), got {array.shape}") + frames: list[list[list[float]]] = [] + for frame in array: + converted = [] + for point in frame: + confidence = float(point[3]) if len(point) > 3 else 1.0 + converted.append([float(point[0]), float(point[1]), float(point[2]), confidence]) + frames.append(converted) + return frames + + +def _validate_recorded_frames(frames: list[list[list[float]]]) -> None: + if not frames: + raise ValueError("no frames found") + for frame_index, frame in enumerate(frames): + if len(frame) != KEYPOINT_COUNT: + raise ValueError(f"frame {frame_index} has {len(frame)} keypoints; expected 33") + for point_index, point in enumerate(frame): + if len(point) < 4: + raise ValueError( + f"frame {frame_index} keypoint {point_index} has {len(point)} values; expected 4", + ) + + +def _stroke_depth(frame_index: int) -> float: + cycle = [20, 80, 150, 240, 320, 240, 150, 80] + return float(cycle[frame_index % len(cycle)]) + + +def _invalid_runtime_diagnostics(fps: float, camera_count: int) -> tuple[str, ...]: + diagnostics = [] + if camera_count <= 0: + diagnostics.append("No cameras available") + if fps <= 0: + diagnostics.append("FPS must be greater than zero") + return tuple(diagnostics) + + +def _point(index: int, x: float, y: float, z: float) -> dict[str, float | int]: + return {"index": index, "x": float(x), "y": float(y), "z": float(z), "confidence": 0.92} + + +def _untracked_point(index: int) -> dict[str, float | int]: + return {"index": index, "x": 0.0, "y": 1000.0, "z": 0.0, "confidence": 0.0} diff --git a/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts b/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts index e10e8f3..b707cf3 100644 --- a/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts +++ b/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts @@ -54,7 +54,12 @@ export async function POST( const health = await checkSidecarHealth(port); if (health.status !== "ready") { return NextResponse.json( - { status: health.status, port }, + { + status: health.status, + port, + diagnostics: health.diagnostics ?? [], + source: health.source ?? null, + }, { status: 409 }, ); } diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx index f82bb35..6f81a0c 100644 --- a/src/app/mocap/page.tsx +++ b/src/app/mocap/page.tsx @@ -60,6 +60,7 @@ import type { import { settings } from "@/lib/settings"; import { checkSidecarHealth, + sidecarReadinessMessage, type SidecarHealth, } from "@/lib/mocap/sidecarClient"; import { resolveSidecarPort } from "@/lib/mocap/sidecarPoseSource"; @@ -263,7 +264,7 @@ export default function MocapCapturePage() { try { const health = await checkSidecarHealth(port); if (health.status !== "ready") { - throw new Error(`Sidecar not ready: ${health.status}`); + throw new Error(sidecarReadinessMessage(health)); } setPoseStatus("ready"); setSidecarHealth(health); diff --git a/src/lib/mocap/freemocapSidecarSource.ts b/src/lib/mocap/freemocapSidecarSource.ts index 560222a..01df11a 100644 --- a/src/lib/mocap/freemocapSidecarSource.ts +++ b/src/lib/mocap/freemocapSidecarSource.ts @@ -156,7 +156,8 @@ export class FreemocapSidecarSource implements PoseCaptureSource { }, ); if (!res.ok) { - throw new Error(`Sidecar session/start failed: ${res.status}`); + const body = (await res.json().catch(() => null)) as unknown; + throw new Error(sidecarConnectFailureMessage(res.status, body)); } const body = (await res.json().catch(() => ({}))) as unknown; if (!isRecord(body)) return {}; @@ -208,6 +209,18 @@ export class FreemocapSidecarSource implements PoseCaptureSource { } } +function sidecarConnectFailureMessage(status: number, body: unknown): string { + if (!isRecord(body)) return `Sidecar session/start failed: ${status}`; + const statusText = optionalString(body.status) ?? optionalString(body.error); + const diagnostics = Array.isArray(body.diagnostics) + ? body.diagnostics.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : []; + const details = [statusText, ...diagnostics].filter(Boolean).join(": "); + return details + ? `Sidecar session/start failed: ${status} (${details})` + : `Sidecar session/start failed: ${status}`; +} + export function encodeSidecarPoseFrame( frame: SidecarKeypointFrame, timestampOriginMs = frame.timestampMs, diff --git a/src/lib/mocap/sidecarClient.ts b/src/lib/mocap/sidecarClient.ts index b7e6ba0..1401e94 100644 --- a/src/lib/mocap/sidecarClient.ts +++ b/src/lib/mocap/sidecarClient.ts @@ -5,6 +5,9 @@ export interface SidecarHealth { fps: number; cameras: number; schemaVersion: number; + source?: string; + calibrationId?: string | null; + diagnostics?: string[]; } export interface SidecarSessionInfo { @@ -36,6 +39,15 @@ export async function checkSidecarHealth(port = SIDECAR_DEFAULT_PORT): Promise; } +export function sidecarReadinessMessage(health: SidecarHealth): string { + const diagnostic = Array.isArray(health.diagnostics) + ? health.diagnostics.find((entry) => typeof entry === "string" && entry.length > 0) + : undefined; + return diagnostic + ? `Sidecar not ready: ${health.status} (${diagnostic})` + : `Sidecar not ready: ${health.status}`; +} + export async function startSidecarSession(port = SIDECAR_DEFAULT_PORT): Promise { const res = await fetch(`http://localhost:${port}/session/start`, { method: "POST" }); if (!res.ok) throw new Error(`Sidecar session/start failed: ${res.status}`); diff --git a/src/lib/mocap/sidecarPoseSource.ts b/src/lib/mocap/sidecarPoseSource.ts index ced08c6..5197c5b 100644 --- a/src/lib/mocap/sidecarPoseSource.ts +++ b/src/lib/mocap/sidecarPoseSource.ts @@ -5,6 +5,7 @@ import { stopSidecarSession, type SidecarHealth, type SidecarSessionInfo, + sidecarReadinessMessage, } from "./sidecarClient"; import type { PoseCaptureSource, @@ -56,7 +57,7 @@ export class FreemocapSidecarSource implements PoseCaptureSource { try { const health = await checkSidecarHealth(this.port); if (health.status !== "ready") { - throw new Error(`Sidecar not ready: ${health.status}`); + throw new Error(sidecarReadinessMessage(health)); } this.currentHealth = health; this.setStatus("ready"); diff --git a/tests/freemocapSidecarSource.test.ts b/tests/freemocapSidecarSource.test.ts index 4766a74..2155b6a 100644 --- a/tests/freemocapSidecarSource.test.ts +++ b/tests/freemocapSidecarSource.test.ts @@ -169,7 +169,10 @@ test("sidecar source propagates start and websocket errors", async () => { /Sidecar session\/start failed: 503/, ); assert.equal(source.status, "error"); - assert.equal(errors[0].message, "Sidecar session/start failed: 503"); + assert.equal( + errors[0].message, + "Sidecar session/start failed: 503 (unreachable)", + ); }, { connectStatus: 503 }, ); diff --git a/tests/sidecarCliContract.test.ts b/tests/sidecarCliContract.test.ts new file mode 100644 index 0000000..17081bd --- /dev/null +++ b/tests/sidecarCliContract.test.ts @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import net from "node:net"; +import { once } from "node:events"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +test("rowing-tracker-sidecar serves the ADR-0005 contract with synthetic frames", async () => { + const port = await getFreePort(); + const sidecar = startSidecar(port, ["--source", "synthetic"]); + + try { + const health = await waitForHealth(port); + assert.equal(health.status, "ready"); + assert.equal(health.schemaVersion, 2); + assert.equal(health.cameras, 3); + assert.equal(health.fps, 30); + assert.equal(health.source, "synthetic"); + + const startRes = await fetch(`http://localhost:${port}/session/start`, { + method: "POST", + }); + assert.equal(startRes.status, 200); + const started = (await startRes.json()) as { + sessionId?: string; + calibrationId?: string; + }; + assert.ok(started.sessionId); + assert.ok(started.calibrationId); + + const frame = await readOneSidecarFrame(port); + assert.equal(frame.schema_version, 2); + assert.equal(frame.source, "sidecar-3d"); + assert.equal(frame.keypoints.length, 33); + assert.equal(frame.quality.camera_count, 3); + assert.equal(typeof frame.keypoints[0].z, "number"); + + const stopRes = await fetch(`http://localhost:${port}/session/stop`, { + method: "POST", + }); + assert.equal(stopRes.status, 200); + } finally { + await stopProcess(sidecar); + } +}); + +test("rowing-tracker-sidecar streams recorded FreeMoCap-style frames", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "sidecar-freemocap-")); + const dataPath = path.join(root, "frames.json"); + await writeFile( + dataPath, + JSON.stringify({ + frames: [ + Array.from({ length: 33 }, (_, index) => [ + index + 0.5, + index + 100.5, + index + 200.5, + index < 13 ? 0.91 : 0.25, + ]), + ], + }), + ); + + const port = await getFreePort(); + const sidecar = startSidecar(port, [ + "--source", + "freemocap", + "--freemocap-data", + dataPath, + "--fps", + "20", + "--camera-count", + "4", + ]); + + try { + const health = await waitForHealth(port); + assert.equal(health.status, "ready"); + assert.equal(health.source, "freemocap"); + assert.equal(health.fps, 20); + assert.equal(health.cameras, 4); + assert.equal(health.calibrationId, "freemocap-frames"); + + const startRes = await fetch(`http://localhost:${port}/session/start`, { + method: "POST", + }); + assert.equal(startRes.status, 200); + + const frame = await readOneSidecarFrame(port); + assert.equal(frame.source, "sidecar-3d"); + assert.equal(frame.keypoints[12].x, 12.5); + assert.equal(frame.keypoints[12].y, 112.5); + assert.equal(frame.keypoints[12].z, 212.5); + assert.equal(frame.keypoints[12].confidence, 0.91); + assert.equal(frame.quality.tracked_count, 13); + assert.equal(frame.quality.camera_count, 4); + + await fetch(`http://localhost:${port}/session/stop`, { method: "POST" }); + } finally { + await stopProcess(sidecar); + } +}); + +test("rowing-tracker-sidecar refuses unconfigured FreeMoCap capture", async () => { + const port = await getFreePort(); + const sidecar = startSidecar(port, ["--source", "freemocap"]); + + try { + const health = await waitForHealth(port); + assert.equal(health.status, "error"); + assert.equal(health.source, "freemocap"); + assert.deepEqual(health.cameras, 0); + assert.match( + String((health.diagnostics as unknown[])[0]), + /FreeMoCap live camera runtime is not configured/, + ); + + const startRes = await fetch(`http://localhost:${port}/session/start`, { + method: "POST", + }); + assert.equal(startRes.status, 409); + const startBody = (await startRes.json()) as { status: string }; + assert.equal(startBody.status, "error"); + } finally { + await stopProcess(sidecar); + } +}); + +test("rowing-tracker-sidecar refuses capture when no cameras are available", async () => { + const port = await getFreePort(); + const sidecar = startSidecar(port, [ + "--source", + "synthetic", + "--camera-count", + "0", + ]); + + try { + const health = await waitForHealth(port); + assert.equal(health.status, "error"); + assert.equal(health.cameras, 0); + assert.match( + String((health.diagnostics as unknown[])[0]), + /No cameras available/, + ); + + const startRes = await fetch(`http://localhost:${port}/session/start`, { + method: "POST", + }); + assert.equal(startRes.status, 409); + } finally { + await stopProcess(sidecar); + } +}); + +async function getFreePort(): Promise { + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const port = address.port; + server.close(); + await once(server, "close"); + return port; +} + +function startSidecar( + port: number, + extraArgs: string[], +): ChildProcessWithoutNullStreams { + const sidecarSrc = path.join(process.cwd(), "sidecar", "src"); + const env = { + ...process.env, + PYTHONPATH: process.env.PYTHONPATH + ? `${sidecarSrc}${path.delimiter}${process.env.PYTHONPATH}` + : sidecarSrc, + }; + return spawn( + "python3", + [ + "-m", + "rowing_tracker_sidecar", + "--host", + "127.0.0.1", + "--port", + String(port), + ...extraArgs, + ], + { env }, + ); +} + +async function waitForHealth( + port: number, +): Promise> { + const deadline = Date.now() + 5000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://localhost:${port}/health`); + if (res.ok) return (await res.json()) as Record; + } catch (err) { + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`sidecar did not become healthy: ${String(lastError)}`); +} + +async function readOneSidecarFrame(port: number): Promise<{ + schema_version: number; + source: string; + keypoints: Array<{ + x: number; + y: number; + z: number; + confidence: number; + }>; + quality: { tracked_count: number; camera_count: number }; +}> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}/pose-stream`); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("Timed out waiting for sidecar pose frame")); + }, 5000); + ws.onerror = () => { + clearTimeout(timeout); + reject(new Error("Sidecar WebSocket failed")); + }; + ws.onmessage = (event) => { + clearTimeout(timeout); + ws.close(); + resolve(JSON.parse(String(event.data))); + }; + }); +} + +async function stopProcess(proc: ChildProcessWithoutNullStreams): Promise { + if (proc.exitCode !== null || proc.signalCode !== null) return; + proc.kill("SIGTERM"); + await Promise.race([ + once(proc, "exit"), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); + if (proc.exitCode === null && proc.signalCode === null) { + proc.kill("SIGKILL"); + await once(proc, "exit"); + } +} diff --git a/tests/sidecarPackageInstall.test.ts b/tests/sidecarPackageInstall.test.ts new file mode 100644 index 0000000..0ea4488 --- /dev/null +++ b/tests/sidecarPackageInstall.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { test } from "node:test"; + +const execFileAsync = promisify(execFile); + +test("sidecar package installs locally and exposes rowing-tracker-sidecar", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "sidecar-install-")); + const venv = path.join(root, ".venv"); + + await execFileAsync("python3", ["-m", "venv", venv], { timeout: 30_000 }); + const python = path.join(venv, "bin", "python"); + const command = path.join(venv, "bin", "rowing-tracker-sidecar"); + + await execFileAsync(python, ["-m", "pip", "install", "-e", "sidecar"], { + cwd: process.cwd(), + timeout: 60_000, + }); + const { stdout } = await execFileAsync(command, ["--help"], { + timeout: 30_000, + }); + + assert.match(stdout, /Run the Rowing Tracker local mocap sidecar/); + assert.match(stdout, /--source/); + assert.match(stdout, /--freemocap-data/); +}); diff --git a/tests/sidecarPoseSource.test.ts b/tests/sidecarPoseSource.test.ts index 6a8725b..9af32be 100644 --- a/tests/sidecarPoseSource.test.ts +++ b/tests/sidecarPoseSource.test.ts @@ -99,17 +99,20 @@ test("sidecar source reports non-ready health through status and error callbacks await assert.rejects( () => source.init(), - /Sidecar not ready: initializing/, + /Sidecar not ready: initializing \(camera rig warming up\)/, ); assert.deepEqual(statuses, [ ["loading", undefined], - ["error", "Sidecar not ready: initializing"], + ["error", "Sidecar not ready: initializing (camera rig warming up)"], ]); assert.equal(errors.length, 1); - assert.equal(errors[0].message, "Sidecar not ready: initializing"); + assert.equal( + errors[0].message, + "Sidecar not ready: initializing (camera rig warming up)", + ); }, - { healthResponse: "initializing" }, + { healthResponse: "initializing", healthDiagnostics: ["camera rig warming up"] }, ); }); @@ -153,6 +156,7 @@ async function withSidecarGlobals( }) => Promise, options: { healthResponse?: "ready" | "initializing" | "unreachable"; + healthDiagnostics?: string[]; startResponse?: "ready" | "malformed"; } = {}, ): Promise { @@ -171,6 +175,7 @@ async function withSidecarGlobals( fps: 60, cameras: 3, schemaVersion: 2, + diagnostics: options.healthDiagnostics, }); } if (url.includes("/session/start")) {