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 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