irtsp 0.1.0__py3-none-any.whl
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.
- irtsp/__init__.py +135 -0
- irtsp/aio.py +355 -0
- irtsp/clock.py +99 -0
- irtsp/discovery.py +113 -0
- irtsp/py.typed +0 -0
- irtsp/records.py +409 -0
- irtsp/session.py +619 -0
- irtsp/video.py +215 -0
- irtsp/wire.py +260 -0
- irtsp-0.1.0.dist-info/METADATA +275 -0
- irtsp-0.1.0.dist-info/RECORD +13 -0
- irtsp-0.1.0.dist-info/WHEEL +4 -0
- irtsp-0.1.0.dist-info/licenses/LICENSE +21 -0
irtsp/__init__.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""irtsp — friendly Python client for iRTSP phones.
|
|
2
|
+
|
|
3
|
+
Read your iPhone's camera + IMU / GPS / LiDAR-depth / ARKit-pose streams as
|
|
4
|
+
typed, unit-aware (SI) records — all on one shared clock, so video and
|
|
5
|
+
odometry align with **no time offset to estimate**.
|
|
6
|
+
|
|
7
|
+
The 3-line tour::
|
|
8
|
+
|
|
9
|
+
import irtsp
|
|
10
|
+
|
|
11
|
+
with irtsp.connect("192.168.1.24") as phone:
|
|
12
|
+
for imu in phone.imu:
|
|
13
|
+
print(imu.gyro, imu.accel) # rad/s, m/s²
|
|
14
|
+
|
|
15
|
+
Pattern-match the whole odometry channel::
|
|
16
|
+
|
|
17
|
+
with irtsp.connect(phone_ip, depth=True) as phone:
|
|
18
|
+
for rec in phone.odometry:
|
|
19
|
+
match rec:
|
|
20
|
+
case irtsp.IMU(gyro=g): ...
|
|
21
|
+
case irtsp.GNSS() as fix: ...
|
|
22
|
+
case irtsp.Pose(position=p): ...
|
|
23
|
+
case irtsp.DepthFrame() as d: ...
|
|
24
|
+
|
|
25
|
+
Extras: ``irtsp[numpy]`` (depth arrays), ``irtsp[discovery]`` (Bonjour),
|
|
26
|
+
``irtsp[video]`` (frames + ``synced()`` bundles, experimental).
|
|
27
|
+
|
|
28
|
+
Wire format & synchronization deep-dive:
|
|
29
|
+
https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
from .clock import StreamClock, interpolate_pose, slerp
|
|
37
|
+
from .records import (
|
|
38
|
+
STANDARD_GRAVITY,
|
|
39
|
+
Altitude,
|
|
40
|
+
DepthFrame,
|
|
41
|
+
GNSS,
|
|
42
|
+
Heading,
|
|
43
|
+
IMU,
|
|
44
|
+
Intrinsics,
|
|
45
|
+
Pose,
|
|
46
|
+
Quat,
|
|
47
|
+
RawAccel,
|
|
48
|
+
RawGyro,
|
|
49
|
+
Record,
|
|
50
|
+
Tracking,
|
|
51
|
+
Unknown,
|
|
52
|
+
Vec3,
|
|
53
|
+
)
|
|
54
|
+
from .session import Handshake, RecordStream, Session, connect
|
|
55
|
+
from .wire import ConnectionClosed, ProtocolError, RecordType
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
58
|
+
from .aio import AsyncRecordStream, AsyncSession
|
|
59
|
+
from .discovery import Device
|
|
60
|
+
|
|
61
|
+
__version__ = "0.1.0"
|
|
62
|
+
|
|
63
|
+
# Names resolved lazily (PEP 562) so `import irtsp` stays dependency-free and
|
|
64
|
+
# fast, while `irtsp.Device` / `irtsp.AsyncSession` still work at runtime.
|
|
65
|
+
_LAZY = {
|
|
66
|
+
"Device": ("irtsp.discovery", "Device"),
|
|
67
|
+
"AsyncSession": ("irtsp.aio", "AsyncSession"),
|
|
68
|
+
"AsyncRecordStream": ("irtsp.aio", "AsyncRecordStream"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __getattr__(name: str) -> Any:
|
|
73
|
+
if name in _LAZY:
|
|
74
|
+
import importlib
|
|
75
|
+
|
|
76
|
+
module_name, attr = _LAZY[name]
|
|
77
|
+
return getattr(importlib.import_module(module_name), attr)
|
|
78
|
+
raise AttributeError(f"module 'irtsp' has no attribute {name!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def __dir__() -> "list[str]":
|
|
82
|
+
return sorted(list(globals()) + list(_LAZY))
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
# connecting
|
|
86
|
+
"connect",
|
|
87
|
+
"connect_async",
|
|
88
|
+
"discover",
|
|
89
|
+
"Session",
|
|
90
|
+
"AsyncSession",
|
|
91
|
+
"AsyncRecordStream",
|
|
92
|
+
"Device",
|
|
93
|
+
"Handshake",
|
|
94
|
+
"RecordStream",
|
|
95
|
+
# records
|
|
96
|
+
"Record",
|
|
97
|
+
"IMU",
|
|
98
|
+
"RawGyro",
|
|
99
|
+
"RawAccel",
|
|
100
|
+
"Intrinsics",
|
|
101
|
+
"GNSS",
|
|
102
|
+
"Altitude",
|
|
103
|
+
"Heading",
|
|
104
|
+
"Pose",
|
|
105
|
+
"DepthFrame",
|
|
106
|
+
"Unknown",
|
|
107
|
+
# value types & constants
|
|
108
|
+
"Vec3",
|
|
109
|
+
"Quat",
|
|
110
|
+
"Tracking",
|
|
111
|
+
"STANDARD_GRAVITY",
|
|
112
|
+
# clock
|
|
113
|
+
"StreamClock",
|
|
114
|
+
"slerp",
|
|
115
|
+
"interpolate_pose",
|
|
116
|
+
# protocol
|
|
117
|
+
"RecordType",
|
|
118
|
+
"ProtocolError",
|
|
119
|
+
"ConnectionClosed",
|
|
120
|
+
"__version__",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def discover(timeout: float = 2.0) -> "list[Device]":
|
|
125
|
+
"""Find iRTSP phones on the local network (needs ``irtsp[discovery]``)."""
|
|
126
|
+
from .discovery import discover as _discover
|
|
127
|
+
|
|
128
|
+
return _discover(timeout=timeout)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def connect_async(target: "str | Any", **kwargs: Any) -> "AsyncSession":
|
|
132
|
+
"""Async twin of :func:`connect` — see :mod:`irtsp.aio`."""
|
|
133
|
+
from .aio import connect_async as _connect_async
|
|
134
|
+
|
|
135
|
+
return await _connect_async(target, **kwargs)
|
irtsp/aio.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""asyncio flavor of the client — same shapes, ``async`` everywhere.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
import asyncio, irtsp
|
|
6
|
+
|
|
7
|
+
async def main():
|
|
8
|
+
async with await irtsp.connect_async("192.168.1.24") as phone:
|
|
9
|
+
async for imu in phone.imu:
|
|
10
|
+
print(imu.gyro, imu.accel)
|
|
11
|
+
|
|
12
|
+
asyncio.run(main())
|
|
13
|
+
|
|
14
|
+
This is a native asyncio implementation (not a thread wrapper): one reader task
|
|
15
|
+
per channel, fanning records out to independent bounded queues with the same
|
|
16
|
+
drop-oldest-per-consumer policy as the sync :class:`~irtsp.Session`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import struct
|
|
25
|
+
from dataclasses import replace
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar
|
|
27
|
+
|
|
28
|
+
from .clock import StreamClock
|
|
29
|
+
from .records import (
|
|
30
|
+
Altitude,
|
|
31
|
+
DepthFrame,
|
|
32
|
+
GNSS,
|
|
33
|
+
Heading,
|
|
34
|
+
IMU,
|
|
35
|
+
Intrinsics,
|
|
36
|
+
Pose,
|
|
37
|
+
Record,
|
|
38
|
+
)
|
|
39
|
+
from .session import DEFAULT_BUFFER, Handshake, _check_handshake
|
|
40
|
+
from .wire import (
|
|
41
|
+
MAX_MESSAGE,
|
|
42
|
+
RECORD_SIZE,
|
|
43
|
+
ProtocolError,
|
|
44
|
+
decode_depth_frame,
|
|
45
|
+
decode_record,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
49
|
+
from .discovery import Device
|
|
50
|
+
|
|
51
|
+
__all__ = ["connect_async", "AsyncSession", "AsyncRecordStream"]
|
|
52
|
+
|
|
53
|
+
log = logging.getLogger("irtsp.aio")
|
|
54
|
+
|
|
55
|
+
R = TypeVar("R", bound=Record)
|
|
56
|
+
|
|
57
|
+
_EOS: Any = object() # end-of-stream sentinel
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _recv_handshake(reader: asyncio.StreamReader) -> Handshake:
|
|
61
|
+
(length,) = struct.unpack("<I", await reader.readexactly(4))
|
|
62
|
+
if not 0 < length <= MAX_MESSAGE:
|
|
63
|
+
raise ProtocolError(f"implausible handshake length {length}")
|
|
64
|
+
return Handshake(json.loads(await reader.readexactly(length)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncRecordStream(Generic[R]):
|
|
68
|
+
"""Async-iterable, filtered view of the session's records."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, session: "AsyncSession", types: tuple[type, ...], maxsize: int):
|
|
71
|
+
self._session = session
|
|
72
|
+
self.types = types
|
|
73
|
+
self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=maxsize)
|
|
74
|
+
self._dropped = 0
|
|
75
|
+
self._ended = False
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def dropped(self) -> int:
|
|
79
|
+
"""Records this consumer lost because it wasn't keeping up."""
|
|
80
|
+
return self._dropped
|
|
81
|
+
|
|
82
|
+
def _put(self, item: Any) -> None:
|
|
83
|
+
try:
|
|
84
|
+
self._queue.put_nowait(item)
|
|
85
|
+
except asyncio.QueueFull:
|
|
86
|
+
try:
|
|
87
|
+
self._queue.get_nowait()
|
|
88
|
+
self._dropped += 1
|
|
89
|
+
except asyncio.QueueEmpty: # pragma: no cover — racy but harmless
|
|
90
|
+
pass
|
|
91
|
+
self._queue.put_nowait(item)
|
|
92
|
+
|
|
93
|
+
def pop_all(self) -> list[R]:
|
|
94
|
+
"""Drain everything buffered right now, without blocking."""
|
|
95
|
+
out: list[R] = []
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
item = self._queue.get_nowait()
|
|
99
|
+
except asyncio.QueueEmpty:
|
|
100
|
+
return out
|
|
101
|
+
if item is _EOS:
|
|
102
|
+
self._ended = True
|
|
103
|
+
self._queue.put_nowait(_EOS) # keep the sentinel for __anext__
|
|
104
|
+
return out
|
|
105
|
+
out.append(item)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Unsubscribe. Any pending ``async for`` ends."""
|
|
109
|
+
self._session._unsubscribe(self)
|
|
110
|
+
self._put(_EOS)
|
|
111
|
+
|
|
112
|
+
def __aiter__(self) -> "AsyncRecordStream[R]":
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
async def __anext__(self) -> R:
|
|
116
|
+
if self._ended:
|
|
117
|
+
raise StopAsyncIteration
|
|
118
|
+
item = await self._queue.get()
|
|
119
|
+
if item is _EOS:
|
|
120
|
+
self._ended = True
|
|
121
|
+
raise StopAsyncIteration
|
|
122
|
+
return item
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncSession:
|
|
126
|
+
"""A live async connection. Prefer :func:`irtsp.connect_async`."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, host: str, *, imu_port: int = 8555, depth: bool = False,
|
|
129
|
+
depth_port: int = 8556, buffer: int = DEFAULT_BUFFER):
|
|
130
|
+
self.host = host
|
|
131
|
+
self.imu_port = imu_port
|
|
132
|
+
self.depth_enabled = depth
|
|
133
|
+
self.depth_port = depth_port
|
|
134
|
+
self._buffer = buffer
|
|
135
|
+
|
|
136
|
+
self.info: Handshake | None = None
|
|
137
|
+
self.depth_info: Handshake | None = None
|
|
138
|
+
|
|
139
|
+
self._subs: list[AsyncRecordStream[Any]] = []
|
|
140
|
+
self._callbacks: list[tuple[tuple[type, ...], Callable[[Any], None]]] = []
|
|
141
|
+
self._latest: dict[type, Record] = {}
|
|
142
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
143
|
+
self._writers: list[asyncio.StreamWriter] = []
|
|
144
|
+
self._closed = False
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ setup
|
|
147
|
+
|
|
148
|
+
async def open(self) -> "AsyncSession":
|
|
149
|
+
try:
|
|
150
|
+
reader, writer = await asyncio.open_connection(self.host, self.imu_port)
|
|
151
|
+
self._writers.append(writer)
|
|
152
|
+
info = await _recv_handshake(reader)
|
|
153
|
+
_check_handshake(info, "irtsp-imu")
|
|
154
|
+
self.info = info
|
|
155
|
+
self._tasks.append(
|
|
156
|
+
asyncio.create_task(self._record_loop(reader), name="irtsp-odometry")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if self.depth_enabled:
|
|
160
|
+
dreader, dwriter = await asyncio.open_connection(self.host, self.depth_port)
|
|
161
|
+
self._writers.append(dwriter)
|
|
162
|
+
depth_info = await _recv_handshake(dreader)
|
|
163
|
+
_check_handshake(depth_info, "irtsp-depth")
|
|
164
|
+
self.depth_info = depth_info
|
|
165
|
+
self._tasks.append(
|
|
166
|
+
asyncio.create_task(self._depth_loop(dreader), name="irtsp-depth")
|
|
167
|
+
)
|
|
168
|
+
except BaseException:
|
|
169
|
+
await self.aclose() # tear down whatever already started
|
|
170
|
+
raise
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------ reader tasks
|
|
174
|
+
|
|
175
|
+
async def _record_loop(self, reader: asyncio.StreamReader) -> None:
|
|
176
|
+
last_seq: int | None = None
|
|
177
|
+
try:
|
|
178
|
+
while True:
|
|
179
|
+
buf = await reader.readexactly(RECORD_SIZE)
|
|
180
|
+
record = decode_record(buf)
|
|
181
|
+
# Server replays the latest Intrinsics (stale seq) to late
|
|
182
|
+
# joiners — don't let it baseline the gap tracker.
|
|
183
|
+
if last_seq is None and isinstance(record, Intrinsics):
|
|
184
|
+
self._dispatch(record, None)
|
|
185
|
+
continue
|
|
186
|
+
last_seq = self._dispatch(record, last_seq)
|
|
187
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
188
|
+
pass
|
|
189
|
+
except ProtocolError as e:
|
|
190
|
+
log.error("odometry stream desynced: %s", e)
|
|
191
|
+
finally:
|
|
192
|
+
await self.aclose()
|
|
193
|
+
|
|
194
|
+
async def _depth_loop(self, reader: asyncio.StreamReader) -> None:
|
|
195
|
+
last_seq: int | None = None
|
|
196
|
+
try:
|
|
197
|
+
while True:
|
|
198
|
+
(length,) = struct.unpack("<I", await reader.readexactly(4))
|
|
199
|
+
if not 0 < length <= MAX_MESSAGE:
|
|
200
|
+
raise ProtocolError(f"implausible depth frame length {length}")
|
|
201
|
+
frame = decode_depth_frame(await reader.readexactly(length))
|
|
202
|
+
last_seq = self._dispatch(frame, last_seq)
|
|
203
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
204
|
+
pass
|
|
205
|
+
except ProtocolError as e:
|
|
206
|
+
log.error("depth stream desynced: %s", e)
|
|
207
|
+
finally:
|
|
208
|
+
await self.aclose()
|
|
209
|
+
|
|
210
|
+
def _dispatch(self, record: Record, last_seq: int | None) -> int:
|
|
211
|
+
if last_seq is not None:
|
|
212
|
+
gap = (record.seq - last_seq - 1) & 0xFFFF
|
|
213
|
+
if gap:
|
|
214
|
+
record = replace(record, gap=gap)
|
|
215
|
+
self._latest[type(record)] = record
|
|
216
|
+
for sub in self._subs:
|
|
217
|
+
if isinstance(record, sub.types):
|
|
218
|
+
sub._put(record)
|
|
219
|
+
for types, fn in self._callbacks:
|
|
220
|
+
if isinstance(record, types):
|
|
221
|
+
try:
|
|
222
|
+
fn(record)
|
|
223
|
+
except Exception:
|
|
224
|
+
log.exception("irtsp callback %r raised", fn)
|
|
225
|
+
return record.seq
|
|
226
|
+
|
|
227
|
+
# -------------------------------------------------------------- consuming
|
|
228
|
+
|
|
229
|
+
def stream(self, *types: type[R], buffer: int | None = None) -> AsyncRecordStream[R]:
|
|
230
|
+
"""A new independent async iterator over the given record types."""
|
|
231
|
+
sub: AsyncRecordStream[R] = AsyncRecordStream(
|
|
232
|
+
self, types or (Record,), buffer or self._buffer
|
|
233
|
+
)
|
|
234
|
+
if self._closed:
|
|
235
|
+
sub._put(_EOS)
|
|
236
|
+
else:
|
|
237
|
+
self._subs.append(sub)
|
|
238
|
+
return sub
|
|
239
|
+
|
|
240
|
+
def _unsubscribe(self, sub: AsyncRecordStream[Any]) -> None:
|
|
241
|
+
if sub in self._subs:
|
|
242
|
+
self._subs.remove(sub)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def odometry(self) -> AsyncRecordStream[Record]:
|
|
246
|
+
return self.stream()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def imu(self) -> AsyncRecordStream[IMU]:
|
|
250
|
+
return self.stream(IMU)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def intrinsics(self) -> AsyncRecordStream[Intrinsics]:
|
|
254
|
+
return self.stream(Intrinsics)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def gnss(self) -> AsyncRecordStream[GNSS]:
|
|
258
|
+
return self.stream(GNSS)
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def altitude(self) -> AsyncRecordStream[Altitude]:
|
|
262
|
+
return self.stream(Altitude)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def heading(self) -> AsyncRecordStream[Heading]:
|
|
266
|
+
return self.stream(Heading)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def pose(self) -> AsyncRecordStream[Pose]:
|
|
270
|
+
return self.stream(Pose)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def depth(self) -> AsyncRecordStream[DepthFrame]:
|
|
274
|
+
if not self.depth_enabled:
|
|
275
|
+
raise RuntimeError("depth channel not enabled — connect_async(host, depth=True)")
|
|
276
|
+
return self.stream(DepthFrame)
|
|
277
|
+
|
|
278
|
+
def on(self, types: type[R] | tuple[type[R], ...], callback: Callable[[R], None]) -> None:
|
|
279
|
+
"""Call ``callback(record)`` for matching records (on the event loop)."""
|
|
280
|
+
filt = types if isinstance(types, tuple) else (types,)
|
|
281
|
+
self._callbacks.append((filt, callback))
|
|
282
|
+
|
|
283
|
+
def latest(self, rtype: type[R]) -> R | None:
|
|
284
|
+
"""The most recent record of exactly ``rtype`` seen, or ``None``."""
|
|
285
|
+
return self._latest.get(rtype) # type: ignore[return-value]
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def clock(self) -> StreamClock:
|
|
289
|
+
if self.info is None:
|
|
290
|
+
raise RuntimeError("session is not open")
|
|
291
|
+
return self.info.clock
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------- teardown
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def closed(self) -> bool:
|
|
297
|
+
return self._closed
|
|
298
|
+
|
|
299
|
+
async def aclose(self) -> None:
|
|
300
|
+
"""Close connections and end all iterators. Idempotent."""
|
|
301
|
+
if self._closed:
|
|
302
|
+
return
|
|
303
|
+
self._closed = True
|
|
304
|
+
for writer in self._writers:
|
|
305
|
+
try:
|
|
306
|
+
writer.close()
|
|
307
|
+
except Exception: # pragma: no cover
|
|
308
|
+
pass
|
|
309
|
+
for sub in self._subs:
|
|
310
|
+
sub._put(_EOS)
|
|
311
|
+
# Cancel and await the reader tasks — except the current one: the
|
|
312
|
+
# loops call aclose() from their own finally, and awaiting yourself
|
|
313
|
+
# deadlocks.
|
|
314
|
+
current = asyncio.current_task()
|
|
315
|
+
others = [t for t in self._tasks if t is not current]
|
|
316
|
+
for task in others:
|
|
317
|
+
task.cancel()
|
|
318
|
+
if others:
|
|
319
|
+
await asyncio.gather(*others, return_exceptions=True)
|
|
320
|
+
for writer in self._writers:
|
|
321
|
+
try:
|
|
322
|
+
await writer.wait_closed()
|
|
323
|
+
except Exception: # pragma: no cover — transport already gone
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
async def __aenter__(self) -> "AsyncSession":
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
330
|
+
await self.aclose()
|
|
331
|
+
|
|
332
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
333
|
+
state = "closed" if self._closed else "live"
|
|
334
|
+
return f"<irtsp.AsyncSession {self.host}:{self.imu_port} [{state}]>"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def connect_async(
|
|
338
|
+
target: "str | Device",
|
|
339
|
+
*,
|
|
340
|
+
imu_port: int = 8555,
|
|
341
|
+
depth: bool = False,
|
|
342
|
+
depth_port: int = 8556,
|
|
343
|
+
buffer: int = DEFAULT_BUFFER,
|
|
344
|
+
) -> AsyncSession:
|
|
345
|
+
"""Async twin of :func:`irtsp.connect` (odometry + depth channels)."""
|
|
346
|
+
host = target
|
|
347
|
+
if not isinstance(target, str):
|
|
348
|
+
host = target.host
|
|
349
|
+
ports = getattr(target, "ports", {})
|
|
350
|
+
imu_port = ports.get("imu", imu_port)
|
|
351
|
+
depth_port = ports.get("depth", depth_port)
|
|
352
|
+
return await AsyncSession(
|
|
353
|
+
host, # type: ignore[arg-type]
|
|
354
|
+
imu_port=imu_port, depth=depth, depth_port=depth_port, buffer=buffer
|
|
355
|
+
).open()
|
irtsp/clock.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The shared clock, and time interpolation helpers.
|
|
2
|
+
|
|
3
|
+
iRTSP captures **one** anchor pair per streaming session — a host-clock reading
|
|
4
|
+
and the Unix wall time at the same instant — and every stream (video RTP/RTCP,
|
|
5
|
+
IMU, GPS, pose, depth) derives its timestamps from it. That is why the streams
|
|
6
|
+
need no cross-correlation to align: they were never on different clocks.
|
|
7
|
+
|
|
8
|
+
:class:`StreamClock` is that anchor, parsed straight out of the handshake, and
|
|
9
|
+
converts between the two axes. See the integration guide §3–4 for the full
|
|
10
|
+
story of how the video's RTCP Sender Reports land on the same ``unix_ts`` axis.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Mapping
|
|
18
|
+
|
|
19
|
+
from .records import Pose, Quat, Tracking, Vec3
|
|
20
|
+
|
|
21
|
+
__all__ = ["StreamClock", "slerp", "interpolate_pose"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class StreamClock:
|
|
26
|
+
"""The session's host↔wall anchor: ``unix = wall_anchor + (host − host_anchor)``."""
|
|
27
|
+
|
|
28
|
+
host_anchor: float #: host-clock seconds (mach_absolute_time) at anchor instant
|
|
29
|
+
wall_anchor: float #: Unix wall seconds at the same instant
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_handshake(cls, handshake: Mapping[str, Any]) -> "StreamClock":
|
|
33
|
+
clock = handshake.get("clock", {})
|
|
34
|
+
return cls(
|
|
35
|
+
host_anchor=float(clock.get("host_anchor", 0.0)),
|
|
36
|
+
wall_anchor=float(clock.get("wall_anchor", 0.0)),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def to_unix(self, host_ts: float) -> float:
|
|
40
|
+
"""Map a host-clock timestamp onto the wall clock (the RTCP-SR NTP axis)."""
|
|
41
|
+
return self.wall_anchor + (host_ts - self.host_anchor)
|
|
42
|
+
|
|
43
|
+
def to_host(self, unix_ts: float) -> float:
|
|
44
|
+
"""Map a wall-clock timestamp back onto the host clock (the video-PTS axis)."""
|
|
45
|
+
return self.host_anchor + (unix_ts - self.wall_anchor)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def slerp(a: Quat, b: Quat, t: float) -> Quat:
|
|
49
|
+
"""Spherical linear interpolation between two unit quaternions, ``t ∈ [0, 1]``.
|
|
50
|
+
|
|
51
|
+
Takes the shortest path, and degrades gracefully to normalized lerp when the
|
|
52
|
+
quaternions are nearly parallel.
|
|
53
|
+
"""
|
|
54
|
+
ax, ay, az, aw = a
|
|
55
|
+
bx, by, bz, bw = b
|
|
56
|
+
dot = ax * bx + ay * by + az * bz + aw * bw
|
|
57
|
+
if dot < 0.0: # shortest path
|
|
58
|
+
bx, by, bz, bw, dot = -bx, -by, -bz, -bw, -dot
|
|
59
|
+
|
|
60
|
+
if dot > 0.9995: # nearly parallel — nlerp avoids division blow-up
|
|
61
|
+
q = Quat(
|
|
62
|
+
ax + t * (bx - ax),
|
|
63
|
+
ay + t * (by - ay),
|
|
64
|
+
az + t * (bz - az),
|
|
65
|
+
aw + t * (bw - aw),
|
|
66
|
+
)
|
|
67
|
+
return q.normalized()
|
|
68
|
+
|
|
69
|
+
theta0 = math.acos(min(dot, 1.0))
|
|
70
|
+
theta = theta0 * t
|
|
71
|
+
sin_theta0 = math.sin(theta0)
|
|
72
|
+
s0 = math.sin(theta0 - theta) / sin_theta0
|
|
73
|
+
s1 = math.sin(theta) / sin_theta0
|
|
74
|
+
return Quat(ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def interpolate_pose(a: Pose, b: Pose, unix_ts: float) -> Pose:
|
|
78
|
+
"""The pose at ``unix_ts``, interpolated between two bracketing samples.
|
|
79
|
+
|
|
80
|
+
Position is interpolated linearly and orientation spherically; ``tracking``
|
|
81
|
+
reports the *worse* of the two endpoints so degraded tracking is never
|
|
82
|
+
hidden by interpolation. ``unix_ts`` outside ``[a, b]`` is clamped.
|
|
83
|
+
"""
|
|
84
|
+
if b.unix_ts <= a.unix_ts:
|
|
85
|
+
return a
|
|
86
|
+
t = max(0.0, min(1.0, (unix_ts - a.unix_ts) / (b.unix_ts - a.unix_ts)))
|
|
87
|
+
position = Vec3(
|
|
88
|
+
a.position.x + t * (b.position.x - a.position.x),
|
|
89
|
+
a.position.y + t * (b.position.y - a.position.y),
|
|
90
|
+
a.position.z + t * (b.position.z - a.position.z),
|
|
91
|
+
)
|
|
92
|
+
return Pose(
|
|
93
|
+
host_ts=a.host_ts + t * (b.host_ts - a.host_ts),
|
|
94
|
+
unix_ts=a.unix_ts + t * (b.unix_ts - a.unix_ts),
|
|
95
|
+
seq=b.seq,
|
|
96
|
+
position=position,
|
|
97
|
+
orientation=slerp(a.orientation, b.orientation, t),
|
|
98
|
+
tracking=Tracking(min(a.tracking, b.tracking)),
|
|
99
|
+
)
|
irtsp/discovery.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Find iRTSP phones on the local network via Bonjour/mDNS.
|
|
2
|
+
|
|
3
|
+
Requires the ``discovery`` extra::
|
|
4
|
+
|
|
5
|
+
pip install 'irtsp[discovery]'
|
|
6
|
+
|
|
7
|
+
Then::
|
|
8
|
+
|
|
9
|
+
import irtsp
|
|
10
|
+
|
|
11
|
+
for device in irtsp.discover():
|
|
12
|
+
print(device.name, device.host, device.ports)
|
|
13
|
+
|
|
14
|
+
phone = irtsp.discover()[0].connect()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
24
|
+
from .session import Session
|
|
25
|
+
|
|
26
|
+
__all__ = ["Device", "discover"]
|
|
27
|
+
|
|
28
|
+
#: Bonjour service type → channel name.
|
|
29
|
+
_SERVICE_CHANNELS = {
|
|
30
|
+
"_rtsp._tcp.local.": "video",
|
|
31
|
+
"_irtsp-imu._tcp.local.": "imu",
|
|
32
|
+
"_irtsp-depth._tcp.local.": "depth",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Device:
|
|
38
|
+
"""One discovered iRTSP phone."""
|
|
39
|
+
|
|
40
|
+
name: str #: the service name the phone advertises
|
|
41
|
+
host: str #: best address to connect to (IPv4 preferred)
|
|
42
|
+
addresses: list[str] = field(default_factory=list)
|
|
43
|
+
ports: dict[str, int] = field(default_factory=dict) #: e.g. ``{"video": 8554, "imu": 8555}``
|
|
44
|
+
properties: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def connect(self, **kwargs: Any) -> "Session":
|
|
47
|
+
"""Open a session to this phone — same options as :func:`irtsp.connect`."""
|
|
48
|
+
from .session import connect
|
|
49
|
+
|
|
50
|
+
return connect(self, **kwargs)
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
53
|
+
ports = ", ".join(f"{k}:{v}" for k, v in sorted(self.ports.items()))
|
|
54
|
+
return f"<irtsp.Device {self.name!r} {self.host} ({ports})>"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover(timeout: float = 2.0) -> list[Device]:
|
|
58
|
+
"""Browse the local network for iRTSP phones for ``timeout`` seconds.
|
|
59
|
+
|
|
60
|
+
Only devices advertising the iRTSP odometry channel (``_irtsp-imu._tcp``)
|
|
61
|
+
are returned — a plain RTSP camera that isn't iRTSP won't show up.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
|
|
65
|
+
except ImportError as e:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"irtsp.discover() needs zeroconf — pip install 'irtsp[discovery]' "
|
|
68
|
+
"(or connect by IP: irtsp.connect('192.168.1.24'))"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
found: dict[str, Device] = {}
|
|
72
|
+
|
|
73
|
+
class _Listener(ServiceListener):
|
|
74
|
+
def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
75
|
+
info = zc.get_service_info(type_, name, timeout=int(timeout * 1000))
|
|
76
|
+
if info is None:
|
|
77
|
+
return
|
|
78
|
+
service_name = name.removesuffix("." + type_)
|
|
79
|
+
addresses = [addr for addr in info.parsed_scoped_addresses() if ":" not in addr] or list(
|
|
80
|
+
info.parsed_scoped_addresses()
|
|
81
|
+
)
|
|
82
|
+
if not addresses:
|
|
83
|
+
return
|
|
84
|
+
device = found.setdefault(
|
|
85
|
+
service_name, Device(name=service_name, host=addresses[0], addresses=addresses)
|
|
86
|
+
)
|
|
87
|
+
device.ports[_SERVICE_CHANNELS[type_]] = info.port or 0
|
|
88
|
+
for k, v in (info.properties or {}).items():
|
|
89
|
+
try:
|
|
90
|
+
device.properties[k.decode()] = v.decode() if isinstance(v, bytes) else str(v)
|
|
91
|
+
except Exception: # pragma: no cover — malformed TXT records
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
95
|
+
self.add_service(zc, type_, name)
|
|
96
|
+
|
|
97
|
+
def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
zc = Zeroconf()
|
|
101
|
+
try:
|
|
102
|
+
listener = _Listener()
|
|
103
|
+
browsers = [ServiceBrowser(zc, t, listener) for t in _SERVICE_CHANNELS]
|
|
104
|
+
time.sleep(timeout)
|
|
105
|
+
del browsers
|
|
106
|
+
finally:
|
|
107
|
+
zc.close()
|
|
108
|
+
|
|
109
|
+
# A phone must expose the odometry channel to count as an iRTSP device.
|
|
110
|
+
return sorted(
|
|
111
|
+
(d for d in found.values() if "imu" in d.ports),
|
|
112
|
+
key=lambda d: d.name,
|
|
113
|
+
)
|
irtsp/py.typed
ADDED
|
File without changes
|