poseproxy 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ build
2
+ *.o
3
+ *.o.cmd
4
+ .vscode/settings.json
5
+ venv
6
+ ablation_data
7
+ *.tar.gz
8
+
9
+ ablation_checkpoints
10
+ ablation_data*
11
+ .venv
12
+ *.out
13
+ *.wasm
14
+ .idea
15
+ cmake-build*
16
+ *.o.*
17
+ **/__pycache__
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: poseproxy
3
+ Version: 0.0.1
4
+ Summary: Simple Pose Forwarder For Motion Capturing Systems
5
+ Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: websockets<16,>=15.0.1
9
+ Provides-Extra: vicon
10
+ Requires-Dist: pyvicon-datastream==0.2.4; extra == 'vicon'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Pose WebSocket server
14
+
15
+ Streams one rigid body from any mocap system to a browser. Requires Python 3.10+.
16
+
17
+ Run from the repository root:
18
+
19
+ ```sh
20
+ python3 -m venv .venv
21
+ . .venv/bin/activate
22
+ pip install ./pose
23
+ pose --demo
24
+ ```
25
+
26
+ For Vicon:
27
+
28
+ ```sh
29
+ pip install './pose[vicon]'
30
+ pose.vicon --host 192.154.4.124 --list # List current object names
31
+ pose.vicon --host 192.154.4.124 --subject crazyflie | pose
32
+ ```
33
+
34
+ Vicon uses the root segment; override with `--segment NAME`. Server defaults:
35
+ `127.0.0.1:8765`; change with `--host`/`--port`. Stop with Ctrl-C.
36
+
37
+ ## Pose API
38
+
39
+ Connect to `ws://127.0.0.1:8765/pose`. Each text message contains:
40
+
41
+ ```json
42
+ {"timestamp":1789600000.125,"position":[0.1,-0.2,0.5],"quaternion":[0,0,0,1]}
43
+ ```
44
+
45
+ - `timestamp`: Unix seconds when the producer received the sample.
46
+ - `position`: meters, right-handed world frame: X forward, Y left, Z up.
47
+ - `quaternion`: normalized `[x,y,z,w]`, rotating body-local vectors into world coordinates.
48
+
49
+ All values must be finite numbers. The Vicon adapter converts millimeters to meters
50
+ and reorders its SDK quaternion to this shared convention.
51
+
52
+ The stream is read-only. Invalid/occluded poses are skipped; tracking loss means
53
+ silence. Clients should detect stale data. Slow clients skip pending samples;
54
+ new connections receive only new samples. Input EOF stops the server.
55
+
56
+ Other adapters must emit this format as flushed, newline-delimited JSON to
57
+ stdout, with diagnostics on stderr:
58
+
59
+ ```sh
60
+ python -u my_mocap_adapter.py | pose
61
+ ```
62
+
63
+ ## Browser
64
+
65
+ Run Python on the browser's computer. From a Connect button handler:
66
+
67
+ ```js
68
+ const response = await fetch("http://127.0.0.1:8765/health");
69
+ if (!response.ok) throw new Error("Pose server unavailable");
70
+ const socket = new WebSocket("ws://127.0.0.1:8765/pose");
71
+ socket.onmessage = ({data}) => console.log(JSON.parse(data));
72
+ ```
73
+
74
+ Grant Chrome's local/loopback permission. Allowed origins: `https://rc.rl.tools`,
75
+ `http://localhost:8000`, `http://127.0.0.1:8000`; extend with `--allow-origin URL`.
76
+ `/health` returns `last_pose_age_s` (`null` before any sample).
77
+
78
+ Tests: `python -B -m unittest discover -s pose/tests -v`.
@@ -0,0 +1,66 @@
1
+ # Pose WebSocket server
2
+
3
+ Streams one rigid body from any mocap system to a browser. Requires Python 3.10+.
4
+
5
+ Run from the repository root:
6
+
7
+ ```sh
8
+ python3 -m venv .venv
9
+ . .venv/bin/activate
10
+ pip install ./pose
11
+ pose --demo
12
+ ```
13
+
14
+ For Vicon:
15
+
16
+ ```sh
17
+ pip install './pose[vicon]'
18
+ pose.vicon --host 192.154.4.124 --list # List current object names
19
+ pose.vicon --host 192.154.4.124 --subject crazyflie | pose
20
+ ```
21
+
22
+ Vicon uses the root segment; override with `--segment NAME`. Server defaults:
23
+ `127.0.0.1:8765`; change with `--host`/`--port`. Stop with Ctrl-C.
24
+
25
+ ## Pose API
26
+
27
+ Connect to `ws://127.0.0.1:8765/pose`. Each text message contains:
28
+
29
+ ```json
30
+ {"timestamp":1789600000.125,"position":[0.1,-0.2,0.5],"quaternion":[0,0,0,1]}
31
+ ```
32
+
33
+ - `timestamp`: Unix seconds when the producer received the sample.
34
+ - `position`: meters, right-handed world frame: X forward, Y left, Z up.
35
+ - `quaternion`: normalized `[x,y,z,w]`, rotating body-local vectors into world coordinates.
36
+
37
+ All values must be finite numbers. The Vicon adapter converts millimeters to meters
38
+ and reorders its SDK quaternion to this shared convention.
39
+
40
+ The stream is read-only. Invalid/occluded poses are skipped; tracking loss means
41
+ silence. Clients should detect stale data. Slow clients skip pending samples;
42
+ new connections receive only new samples. Input EOF stops the server.
43
+
44
+ Other adapters must emit this format as flushed, newline-delimited JSON to
45
+ stdout, with diagnostics on stderr:
46
+
47
+ ```sh
48
+ python -u my_mocap_adapter.py | pose
49
+ ```
50
+
51
+ ## Browser
52
+
53
+ Run Python on the browser's computer. From a Connect button handler:
54
+
55
+ ```js
56
+ const response = await fetch("http://127.0.0.1:8765/health");
57
+ if (!response.ok) throw new Error("Pose server unavailable");
58
+ const socket = new WebSocket("ws://127.0.0.1:8765/pose");
59
+ socket.onmessage = ({data}) => console.log(JSON.parse(data));
60
+ ```
61
+
62
+ Grant Chrome's local/loopback permission. Allowed origins: `https://rc.rl.tools`,
63
+ `http://localhost:8000`, `http://127.0.0.1:8000`; extend with `--allow-origin URL`.
64
+ `/health` returns `last_pose_age_s` (`null` before any sample).
65
+
66
+ Tests: `python -B -m unittest discover -s pose/tests -v`.
@@ -0,0 +1,5 @@
1
+ # auth in .pypirc
2
+ rm -rf ../pose/dist
3
+ pip install --upgrade build twine
4
+ python3 -m build --sdist
5
+ python3 -m twine upload dist/*
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """Forward newline-delimited JSON poses from stdin to ws://127.0.0.1:8765/pose."""
3
+
4
+ import argparse
5
+ import asyncio
6
+ import concurrent.futures
7
+ import json
8
+ import logging
9
+ import math
10
+ import signal
11
+ import sys
12
+ import threading
13
+ import time
14
+
15
+ from websockets.asyncio.server import serve
16
+ from websockets.exceptions import ConnectionClosed
17
+
18
+
19
+ LOG = logging.getLogger("pose_server")
20
+ DEFAULT_ORIGINS = (
21
+ "https://rc.rl.tools",
22
+ "http://localhost:8000",
23
+ "http://127.0.0.1:8000",
24
+ )
25
+
26
+
27
+ def encode_pose(pose):
28
+ """Validate the common pose format and normalize its xyzw quaternion."""
29
+ def number(value):
30
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
31
+ raise ValueError("pose values must be numbers")
32
+ value = float(value)
33
+ if not math.isfinite(value):
34
+ raise ValueError("pose values must be finite")
35
+ return value
36
+
37
+ def vector(name, size):
38
+ value = pose.get(name)
39
+ if not isinstance(value, list) or len(value) != size:
40
+ raise ValueError(f"{name} must be an array of {size} numbers")
41
+ return [number(item) for item in value]
42
+
43
+ if not isinstance(pose, dict):
44
+ raise ValueError("pose must be a JSON object")
45
+ timestamp = number(pose.get("timestamp"))
46
+ position = vector("position", 3)
47
+ quaternion = vector("quaternion", 4)
48
+ norm = math.hypot(*quaternion)
49
+ if norm == 0 or not math.isfinite(norm):
50
+ raise ValueError("quaternion must have a finite, nonzero norm")
51
+ return json.dumps({
52
+ "timestamp": timestamp,
53
+ "position": position,
54
+ "quaternion": [value / norm for value in quaternion],
55
+ }, separators=(",", ":"), allow_nan=False)
56
+
57
+
58
+ class PoseServer:
59
+ def __init__(self, origins=DEFAULT_ORIGINS):
60
+ self.origins = set(origins)
61
+ self.clients = set()
62
+ self.last_received = None
63
+
64
+ async def publish(self, message):
65
+ """Publish an encoded pose on the event loop; retain one pending pose/client."""
66
+ self.last_received = time.monotonic()
67
+ for queue in self.clients:
68
+ if queue.full():
69
+ queue.get_nowait()
70
+ queue.put_nowait(message)
71
+
72
+ def process_request(self, connection, request):
73
+ origins = request.headers.get_all("Origin")
74
+ if len(origins) > 1 or (origins and origins[0] not in self.origins):
75
+ return connection.respond(403, "Origin not allowed\n")
76
+ if request.path == "/health":
77
+ age = (None if self.last_received is None else
78
+ time.monotonic() - self.last_received)
79
+ response = connection.respond(200, json.dumps({
80
+ "last_pose_age_s": age,
81
+ }) + "\n")
82
+ del response.headers["Content-Type"]
83
+ response.headers["Content-Type"] = "application/json"
84
+ response.headers["Cache-Control"] = "no-store"
85
+ response.headers["Vary"] = "Origin"
86
+ if origins:
87
+ response.headers["Access-Control-Allow-Origin"] = origins[0]
88
+ return response
89
+ if request.path != "/pose":
90
+ return connection.respond(404, "Use /pose or /health\n")
91
+ return None
92
+
93
+ async def handle(self, websocket):
94
+ queue = asyncio.Queue(maxsize=1)
95
+ self.clients.add(queue)
96
+
97
+ async def send_poses():
98
+ while True:
99
+ message = await queue.get()
100
+ try:
101
+ await asyncio.wait_for(websocket.send(message), timeout=1)
102
+ except asyncio.TimeoutError:
103
+ await websocket.close(code=1013, reason="Client is too slow")
104
+ return
105
+
106
+ async def receive():
107
+ # Reading also detects disconnects when the mocap source is silent.
108
+ async for _ in websocket:
109
+ await websocket.close(code=1008, reason="Pose stream is read-only")
110
+ return
111
+
112
+ tasks = [asyncio.create_task(send_poses()), asyncio.create_task(receive())]
113
+ try:
114
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
115
+ for task in done:
116
+ task.result()
117
+ except ConnectionClosed:
118
+ pass
119
+ finally:
120
+ self.clients.discard(queue)
121
+ for task in tasks:
122
+ task.cancel()
123
+ await asyncio.gather(*tasks, return_exceptions=True)
124
+
125
+ def listen(self, host="127.0.0.1", port=8765):
126
+ return serve(
127
+ self.handle, host, port, process_request=self.process_request,
128
+ origins=[None, *self.origins], compression=None,
129
+ max_size=1024, max_queue=1, write_limit=4096, close_timeout=1,
130
+ )
131
+
132
+
133
+ def read_stdin(fd, loop, server, finished):
134
+ """A daemon reader keeps stdin blocking and SDK work out of the event loop."""
135
+ try:
136
+ # Unbuffered IO avoids a blocked buffered-reader lock during Ctrl-C exit.
137
+ with open(fd, "rb", buffering=0, closefd=False) as stream:
138
+ while line := stream.readline(4097):
139
+ if len(line) > 4096:
140
+ LOG.warning("Skipping input line longer than 4096 bytes")
141
+ while line and not line.endswith(b"\n"):
142
+ line = stream.readline(4097)
143
+ continue
144
+ if not line.strip():
145
+ continue
146
+ try:
147
+ message = encode_pose(json.loads(line))
148
+ except (ValueError, OverflowError, RecursionError) as error:
149
+ LOG.warning("Skipping invalid pose: %s", error)
150
+ continue
151
+ # At most one callback is outstanding, even for a fast producer.
152
+ publication = server.publish(message)
153
+ try:
154
+ future = asyncio.run_coroutine_threadsafe(publication, loop)
155
+ except RuntimeError:
156
+ publication.close()
157
+ return
158
+ future.result()
159
+ except (RuntimeError, concurrent.futures.CancelledError):
160
+ pass # Event loop shut down while stdin was still open.
161
+ except OSError as error:
162
+ LOG.error("Reading poses failed: %s", error)
163
+ finally:
164
+ try:
165
+ loop.call_soon_threadsafe(finished.set)
166
+ except RuntimeError:
167
+ pass
168
+
169
+
170
+ async def demo(server, rate):
171
+ start = time.monotonic()
172
+ while True:
173
+ angle = (time.monotonic() - start) * 0.5
174
+ await server.publish(encode_pose({
175
+ "timestamp": time.time(),
176
+ "position": [0.5 * math.cos(angle), 0.5 * math.sin(angle), 1.0],
177
+ "quaternion": [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)],
178
+ }))
179
+ await asyncio.sleep(1 / rate)
180
+
181
+
182
+ async def run(args):
183
+ loop = asyncio.get_running_loop()
184
+ finished = asyncio.Event()
185
+ # asyncio.run handles Ctrl-C; also shut down cleanly on POSIX SIGTERM.
186
+ try:
187
+ loop.add_signal_handler(signal.SIGTERM, finished.set)
188
+ except NotImplementedError:
189
+ pass
190
+ server = PoseServer((*DEFAULT_ORIGINS, *args.allow_origin))
191
+ async with server.listen(args.host, args.port) as listener:
192
+ port = listener.sockets[0].getsockname()[1]
193
+ LOG.info("Pose stream: ws://%s:%s/pose", args.host, port)
194
+ if args.demo:
195
+ producer = asyncio.create_task(demo(server, args.rate))
196
+ else:
197
+ producer = None
198
+ threading.Thread(
199
+ target=read_stdin,
200
+ args=(sys.stdin.fileno(), loop, server, finished), daemon=True,
201
+ ).start()
202
+ try:
203
+ await finished.wait()
204
+ finally:
205
+ if producer is not None:
206
+ producer.cancel()
207
+ await asyncio.gather(producer, return_exceptions=True)
208
+
209
+
210
+ def main():
211
+ parser = argparse.ArgumentParser(description=__doc__)
212
+ parser.add_argument("--host", default="127.0.0.1", help="Bind address")
213
+ parser.add_argument("--port", type=int, default=8765)
214
+ parser.add_argument("--allow-origin", action="append", default=[],
215
+ help="Additional browser origin (repeatable)")
216
+ parser.add_argument("--demo", action="store_true", help="Generate poses instead of reading stdin")
217
+ parser.add_argument("--rate", type=float, default=100, help="Demo rate in Hz (default: 100)")
218
+ args = parser.parse_args()
219
+ if not 0 <= args.port <= 65535:
220
+ parser.error("port must be between 0 and 65535")
221
+ if not math.isfinite(args.rate) or args.rate <= 0:
222
+ parser.error("rate must be finite and positive")
223
+ logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
224
+ try:
225
+ asyncio.run(run(args))
226
+ except KeyboardInterrupt:
227
+ pass
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "poseproxy"
7
+ version = "0.0.1"
8
+ description = "Simple Pose Forwarder For Motion Capturing Systems"
9
+ readme = "README.md"
10
+ authors = [{name = "Jonas Eschmann", email = "jonas.eschmann@gmail.com"}]
11
+ license = {text = "MIT"}
12
+ requires-python = ">=3.10"
13
+ dependencies = ["websockets>=15.0.1,<16"]
14
+
15
+ [project.optional-dependencies]
16
+ vicon = ["pyvicon-datastream==0.2.4"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ only-include = ["pose_server.py", "vicon_poses.py"]
20
+
21
+ [project.scripts]
22
+ pose = "pose_server:main"
23
+ "pose.vicon" = "vicon_poses:main"
@@ -0,0 +1 @@
1
+ websockets>=15.0.1,<16
@@ -0,0 +1,272 @@
1
+ import asyncio
2
+ from contextlib import redirect_stderr, redirect_stdout
3
+ from io import StringIO
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ import signal
8
+ import sys
9
+ import unittest
10
+ from unittest.mock import Mock, patch
11
+
12
+ from websockets.asyncio.client import connect
13
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
14
+
15
+ POSE_DIR = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(POSE_DIR))
17
+ from pose_server import PoseServer, encode_pose
18
+ from vicon_poses import main as vicon_main, poses
19
+
20
+
21
+ POSE = {"timestamp": 1789600000.125, "position": [1, 2, 3], "quaternion": [0, 0, 0, 1]}
22
+ ORIGIN = "https://rc.rl.tools"
23
+
24
+
25
+ class FormatTests(unittest.TestCase):
26
+ def test_normalizes_quaternion_and_preserves_pose(self):
27
+ result = json.loads(encode_pose({**POSE, "quaternion": [0, 0, 2, 2]}))
28
+ self.assertEqual(result["position"], POSE["position"])
29
+ self.assertEqual(result["timestamp"], POSE["timestamp"])
30
+ self.assertAlmostEqual(result["quaternion"][2], 2 ** -0.5)
31
+ self.assertAlmostEqual(result["quaternion"][3], 2 ** -0.5)
32
+
33
+ def test_rejects_invalid_measurements(self):
34
+ invalid = [
35
+ None, [], {}, {**POSE, "position": [1, 2]},
36
+ {**POSE, "position": [1, float("nan"), 3]},
37
+ {**POSE, "quaternion": [0, 0, 0, 0]},
38
+ {**POSE, "quaternion": [0, 0, 0, float("inf")]},
39
+ {**POSE, "timestamp": True}, {**POSE, "timestamp": "123"},
40
+ ]
41
+ for value in invalid:
42
+ with self.subTest(value=value), self.assertRaises(ValueError):
43
+ encode_pose(value)
44
+
45
+
46
+ class ViconTests(unittest.TestCase):
47
+ def make_sdk(self):
48
+ sdk = Mock()
49
+ client = sdk.PyViconDatastream.return_value
50
+ for method in ("connect", "enable_segment_data", "set_stream_mode", "set_axis_mapping", "get_frame"):
51
+ getattr(client, method).return_value = sdk.Result.Success
52
+ client.get_subject_root_segment_name.return_value = "root"
53
+ client.get_segment_global_translation.side_effect = [None, [1000, -2000, 500]]
54
+ client.get_segment_global_quaternion.return_value = [0.5, 0.5, -0.5, 0.5]
55
+ return sdk, client
56
+
57
+ def test_occlusion_units_order_and_cleanup(self):
58
+ sdk, client = self.make_sdk()
59
+ stream = poses(sdk, "vicon:801", "drone")
60
+ pose = next(stream)
61
+ self.assertEqual(pose["position"], [1, -2, 0.5])
62
+ self.assertEqual(pose["quaternion"], [0.5, -0.5, 0.5, 0.5])
63
+ self.assertGreater(pose["timestamp"], 0)
64
+ self.assertEqual(client.get_frame.call_count, 2)
65
+ client.set_buffer_size.assert_called_once_with(1)
66
+ client.set_axis_mapping.assert_called_once_with(
67
+ sdk.Direction.Forward, sdk.Direction.Left, sdk.Direction.Up)
68
+ client.get_segment_global_translation.assert_called_with("drone", "root")
69
+ stream.close()
70
+ client.disconnect.assert_called_once()
71
+
72
+ def test_source_failure_is_reported_and_disconnected(self):
73
+ sdk, client = self.make_sdk()
74
+ client.get_frame.return_value = sdk.Result.NotConnected
75
+ with self.assertRaisesRegex(RuntimeError, "get frame failed"):
76
+ next(poses(sdk, "vicon", "drone"))
77
+ client.disconnect.assert_called_once()
78
+
79
+ def run_list_command(self, sdk):
80
+ stdout, stderr = StringIO(), StringIO()
81
+ with patch.dict(sys.modules, {"pyvicon_datastream": sdk}), \
82
+ patch.object(sys, "argv", ["pose.vicon", "--host", "vicon", "--list"]), \
83
+ redirect_stdout(stdout), redirect_stderr(stderr):
84
+ vicon_main()
85
+ return stdout.getvalue(), stderr.getvalue()
86
+
87
+ def test_list_waits_for_frame_and_prints_names_without_streaming(self):
88
+ sdk, client = self.make_sdk()
89
+ client.get_frame.side_effect = [sdk.Result.NoFrame, sdk.Result.Success]
90
+ client.get_subject_count.return_value = 2
91
+ client.get_subject_name.side_effect = ["crazyflie", "Calibration Wand"]
92
+ stdout, stderr = self.run_list_command(sdk)
93
+ self.assertEqual(stdout, "crazyflie\nCalibration Wand\n")
94
+ self.assertEqual(stderr, "")
95
+ self.assertEqual(client.get_frame.call_count, 2)
96
+ client.get_subject_root_segment_name.assert_not_called()
97
+ client.get_segment_global_translation.assert_not_called()
98
+ client.disconnect.assert_called_once()
99
+
100
+ def test_list_handles_empty_frame(self):
101
+ sdk, client = self.make_sdk()
102
+ client.get_subject_count.return_value = 0
103
+ stdout, stderr = self.run_list_command(sdk)
104
+ self.assertEqual(stdout, "")
105
+ self.assertEqual(stderr, "No objects found.\n")
106
+ client.disconnect.assert_called_once()
107
+
108
+ def test_list_connection_failure_exits_with_error_and_disconnects(self):
109
+ sdk, client = self.make_sdk()
110
+ client.connect.return_value = sdk.Result.ClientConnectionFailed
111
+ with self.assertRaises(SystemExit) as caught:
112
+ self.run_list_command(sdk)
113
+ self.assertEqual(caught.exception.code, 1)
114
+ client.get_subject_count.assert_not_called()
115
+ client.disconnect.assert_called_once()
116
+
117
+
118
+ async def http_get(port, path="/health", origin=ORIGIN):
119
+ reader, writer = await asyncio.open_connection("127.0.0.1", port)
120
+ origin_header = "" if origin is None else f"Origin: {origin}\r\n"
121
+ writer.write((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
122
+ f"{origin_header}Connection: close\r\n\r\n").encode())
123
+ await writer.drain()
124
+ response = await asyncio.wait_for(reader.read(), 2)
125
+ writer.close()
126
+ await writer.wait_closed()
127
+ header, body = response.decode().split("\r\n\r\n", 1)
128
+ return header, body
129
+
130
+
131
+ class ServerTests(unittest.IsolatedAsyncioTestCase):
132
+ async def asyncSetUp(self):
133
+ self.server = PoseServer()
134
+ self.listener = await self.server.listen(port=0)
135
+ self.port = self.listener.sockets[0].getsockname()[1]
136
+ self.url = f"ws://127.0.0.1:{self.port}/pose"
137
+
138
+ async def asyncTearDown(self):
139
+ self.listener.close()
140
+ await self.listener.wait_closed()
141
+
142
+ def connect(self, **kwargs):
143
+ return connect(self.url, origin=ORIGIN, proxy=None, **kwargs)
144
+
145
+ async def test_fanout_without_replaying_stale_poses(self):
146
+ await self.server.publish(encode_pose(POSE))
147
+ async with self.connect() as first, self.connect() as second:
148
+ with self.assertRaises(asyncio.TimeoutError):
149
+ await asyncio.wait_for(first.recv(), 0.05)
150
+ next_pose = {**POSE, "timestamp": POSE["timestamp"] + 1}
151
+ await self.server.publish(encode_pose(next_pose))
152
+ for client in (first, second):
153
+ received = json.loads(await asyncio.wait_for(client.recv(), 1))
154
+ self.assertEqual(received, next_pose)
155
+
156
+ async def test_health_cors_and_origin_rejection(self):
157
+ headers, body = await http_get(self.port)
158
+ self.assertIn("200 OK", headers)
159
+ self.assertIn(f"Access-Control-Allow-Origin: {ORIGIN}", headers)
160
+ self.assertEqual(headers.lower().count("content-type:"), 1)
161
+ self.assertEqual(json.loads(body), {"last_pose_age_s": None})
162
+ await self.server.publish(encode_pose(POSE))
163
+ _, body = await http_get(self.port)
164
+ self.assertGreaterEqual(json.loads(body)["last_pose_age_s"], 0)
165
+ headers, _ = await http_get(self.port, origin="https://unapproved.example")
166
+ self.assertIn("403 Forbidden", headers)
167
+ self.assertNotIn("Access-Control-Allow-Origin", headers)
168
+ with self.assertRaises(InvalidStatus) as caught:
169
+ async with connect(self.url, origin="https://unapproved.example", proxy=None):
170
+ pass
171
+ self.assertEqual(caught.exception.response.status_code, 403)
172
+ headers, _ = await http_get(self.port, path="/unknown")
173
+ self.assertIn("404 Not Found", headers)
174
+
175
+ async def test_client_cannot_inject_poses(self):
176
+ async with self.connect() as client:
177
+ await client.send(json.dumps(POSE))
178
+ with self.assertRaises(ConnectionClosed):
179
+ await asyncio.wait_for(client.recv(), 1)
180
+ self.assertEqual(client.close_code, 1008)
181
+
182
+ async def test_slow_client_skips_pending_frames_without_blocking_others(self):
183
+ blocked = asyncio.Event()
184
+ release = asyncio.Event()
185
+ received = asyncio.Queue()
186
+
187
+ class SlowClient:
188
+ async def send(self, message):
189
+ blocked.set()
190
+ await release.wait()
191
+ await received.put(json.loads(message))
192
+
193
+ def __aiter__(self):
194
+ return self
195
+
196
+ async def __anext__(self):
197
+ await asyncio.Future()
198
+
199
+ task = asyncio.create_task(self.server.handle(SlowClient()))
200
+ try:
201
+ async with self.connect() as fast:
202
+ await self.server.publish(encode_pose(POSE))
203
+ await asyncio.wait_for(blocked.wait(), 1)
204
+ await asyncio.wait_for(fast.recv(), 1)
205
+ for timestamp in (2, 3, 4):
206
+ await self.server.publish(encode_pose({**POSE, "timestamp": timestamp}))
207
+ self.assertEqual(json.loads(await asyncio.wait_for(fast.recv(), 1))["timestamp"], 4)
208
+ release.set()
209
+ self.assertEqual((await asyncio.wait_for(received.get(), 1))["timestamp"], POSE["timestamp"])
210
+ self.assertEqual((await asyncio.wait_for(received.get(), 1))["timestamp"], 4)
211
+ finally:
212
+ task.cancel()
213
+ await asyncio.gather(task, return_exceptions=True)
214
+
215
+
216
+ class CommandTests(unittest.IsolatedAsyncioTestCase):
217
+ async def launch(self, *args):
218
+ process = await asyncio.create_subprocess_exec(
219
+ sys.executable, "-B", str(POSE_DIR / "pose_server.py"), "--port", "0", *args,
220
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
221
+ stderr=asyncio.subprocess.PIPE,
222
+ )
223
+ async def cleanup():
224
+ if process.returncode is None:
225
+ process.terminate()
226
+ await asyncio.wait_for(process.communicate(), 5)
227
+ self.addAsyncCleanup(cleanup)
228
+ while line := await asyncio.wait_for(process.stderr.readline(), 5):
229
+ if match := re.search(rb"Pose stream: ws://127.0.0.1:(\d+)/pose", line):
230
+ return process, int(match[1])
231
+ self.fail("server did not start")
232
+
233
+ async def test_stdin_validation_streaming_and_eof(self):
234
+ process, port = await self.launch()
235
+ async with connect(f"ws://127.0.0.1:{port}/pose", origin=ORIGIN, proxy=None) as client:
236
+ process.stdin.write(b"not json\n" + b"x" * 5000 + b"\n" + json.dumps(POSE).encode() + b"\n")
237
+ await process.stdin.drain()
238
+ self.assertEqual(json.loads(await asyncio.wait_for(client.recv(), 2)), POSE)
239
+ process.stdin.close()
240
+ await asyncio.wait_for(process.wait(), 3)
241
+ self.assertEqual(process.returncode, 0)
242
+ with self.assertRaises(ConnectionClosed):
243
+ await asyncio.wait_for(client.recv(), 1)
244
+
245
+ async def test_demo_and_shutdown_with_open_stdin(self):
246
+ process, port = await self.launch("--demo", "--rate", "50")
247
+ async with connect(f"ws://127.0.0.1:{port}/pose", origin=ORIGIN, proxy=None) as client:
248
+ first = json.loads(await asyncio.wait_for(client.recv(), 2))
249
+ second = json.loads(await asyncio.wait_for(client.recv(), 2))
250
+ self.assertGreater(second["timestamp"], first["timestamp"])
251
+ self.assertEqual(first["position"][2], 1)
252
+ self.assertAlmostEqual(sum(q*q for q in first["quaternion"]), 1)
253
+ process.terminate()
254
+ await asyncio.wait_for(process.wait(), 3)
255
+ self.assertEqual(process.returncode, 0)
256
+
257
+ async def test_shutdown_while_waiting_for_stdin(self):
258
+ process, _ = await self.launch()
259
+ process.terminate()
260
+ await asyncio.wait_for(process.wait(), 3)
261
+ self.assertEqual(process.returncode, 0)
262
+
263
+ @unittest.skipIf(sys.platform == "win32", "POSIX Ctrl-C signal")
264
+ async def test_ctrl_c_while_waiting_for_stdin(self):
265
+ process, _ = await self.launch()
266
+ process.send_signal(signal.SIGINT)
267
+ await asyncio.wait_for(process.wait(), 3)
268
+ self.assertEqual(process.returncode, 0)
269
+
270
+
271
+ if __name__ == "__main__":
272
+ unittest.main()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Emit one Vicon body's poses as newline-delimited JSON for pose_server.py."""
3
+
4
+ import argparse
5
+ from contextlib import closing
6
+ import json
7
+ import sys
8
+ import time
9
+
10
+
11
+ def frames(sdk, host):
12
+ client = sdk.PyViconDatastream()
13
+
14
+ def check(result, action):
15
+ if result != sdk.Result.Success:
16
+ raise RuntimeError(f"Vicon {action} failed: {result}")
17
+
18
+ try:
19
+ check(client.connect(host), f"connection to {host}")
20
+ check(client.enable_segment_data(), "enable segment data")
21
+ client.set_buffer_size(1)
22
+ check(client.set_stream_mode(sdk.StreamMode.ServerPush), "set stream mode")
23
+ check(client.set_axis_mapping(
24
+ sdk.Direction.Forward, sdk.Direction.Left, sdk.Direction.Up,
25
+ ), "set axis mapping")
26
+ while True:
27
+ result = client.get_frame()
28
+ if result == sdk.Result.NoFrame:
29
+ time.sleep(0.001)
30
+ continue
31
+ check(result, "get frame")
32
+ yield client, time.time()
33
+ finally:
34
+ client.disconnect()
35
+
36
+
37
+ def list_objects(sdk, host):
38
+ with closing(frames(sdk, host)) as stream:
39
+ client, _ = next(stream)
40
+ return [client.get_subject_name(index)
41
+ for index in range(client.get_subject_count())]
42
+
43
+
44
+ def poses(sdk, host, subject, segment=None):
45
+ with closing(frames(sdk, host)) as stream:
46
+ for client, timestamp in stream:
47
+ if segment is None:
48
+ segment = client.get_subject_root_segment_name(subject)
49
+ if not segment:
50
+ raise RuntimeError(f"Vicon subject {subject!r} has no root segment")
51
+ position = client.get_segment_global_translation(subject, segment)
52
+ rotation = client.get_segment_global_quaternion(subject, segment)
53
+ # The wrapper returns None for occluded or unavailable segment data.
54
+ if position is None or rotation is None:
55
+ continue
56
+ w, x, y, z = map(float, rotation)
57
+ yield {
58
+ "timestamp": timestamp,
59
+ "position": [float(value) * 0.001 for value in position],
60
+ "quaternion": [x, y, z, w],
61
+ }
62
+
63
+
64
+ def main():
65
+ parser = argparse.ArgumentParser(description=__doc__)
66
+ parser.add_argument("--host", required=True, help="Vicon host[:port] (default port: 801)")
67
+ parser.add_argument("--subject", default="crazyflie")
68
+ parser.add_argument("--segment", help="Default: subject's root segment")
69
+ parser.add_argument("--list", action="store_true", dest="list_objects",
70
+ help="List current object (subject) names and exit")
71
+ args = parser.parse_args()
72
+ try:
73
+ import pyvicon_datastream as sdk
74
+ except ImportError:
75
+ parser.exit(1, "Install the Vicon adapter dependency: pip install pyvicon-datastream==0.2.4\n")
76
+ stream = None
77
+ try:
78
+ if args.list_objects:
79
+ names = list_objects(sdk, args.host)
80
+ for name in names:
81
+ print(name)
82
+ if not names:
83
+ print("No objects found.", file=sys.stderr)
84
+ return
85
+ stream = poses(sdk, args.host, args.subject, args.segment)
86
+ for pose in stream:
87
+ print(json.dumps(pose, allow_nan=False), flush=True)
88
+ except (KeyboardInterrupt, BrokenPipeError):
89
+ pass
90
+ except (RuntimeError, ValueError) as error:
91
+ parser.exit(1, f"{error}\n")
92
+ finally:
93
+ if stream is not None:
94
+ stream.close()
95
+ # Avoid retrying a failed stdout flush during interpreter shutdown.
96
+ if sys.stdout is not None:
97
+ try:
98
+ sys.stdout.flush()
99
+ except BrokenPipeError:
100
+ sys.stdout = None
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()