irtsp 0.1.0__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,30 @@
1
+ # Test on every push/PR: oldest & newest supported Python, Linux + macOS.
2
+ name: CI
3
+
4
+ on:
5
+ push:
6
+ branches: [main]
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest]
19
+ python: ["3.10", "3.13", "3.14"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - name: Install
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ python -m pip install -e . pytest numpy
29
+ - name: Test
30
+ run: python -m pytest
@@ -0,0 +1,45 @@
1
+ # Publish irtsp to PyPI when a GitHub release is published.
2
+ #
3
+ # Uses PyPI Trusted Publishing (OIDC) — no API token stored in the repo.
4
+ # One-time setup on pypi.org → project "irtsp" → Publishing → add publisher:
5
+ # owner: ryanrudes repo: irtsp-python workflow: release.yml environment: pypi
6
+ name: Release
7
+
8
+ on:
9
+ release:
10
+ types: [published]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ - name: Build sdist + wheel
24
+ run: |
25
+ python -m pip install --upgrade build
26
+ python -m build
27
+ - uses: actions/upload-artifact@v4
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/p/irtsp
38
+ permissions:
39
+ id-token: write # OIDC for PyPI Trusted Publishing
40
+ steps:
41
+ - uses: actions/download-artifact@v4
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+ - uses: pypa/gh-action-pypi-publish@release/v1
irtsp-0.1.0/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
irtsp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Rudes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
irtsp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: irtsp
3
+ Version: 0.1.0
4
+ Summary: Friendly Python client for iRTSP — typed, time-synced iPhone camera + IMU / GPS / LiDAR-depth / ARKit-pose streams
5
+ Project-URL: Homepage, https://github.com/ryanrudes/irtsp-python
6
+ Project-URL: Documentation, https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md
7
+ Project-URL: Support, https://ryanrudes.github.io/irtsp-support/
8
+ Project-URL: Source, https://github.com/ryanrudes/irtsp-python
9
+ Author-email: Ryan Rudes <ryanrudes@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: arkit,camera,imu,ios,iphone,lidar,robotics,rtsp,sensors,slam,vio,visual-inertial-odometry
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Multimedia :: Video :: Capture
25
+ Classifier: Topic :: Scientific/Engineering
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: all
29
+ Requires-Dist: av>=10; extra == 'all'
30
+ Requires-Dist: numpy>=1.22; extra == 'all'
31
+ Requires-Dist: zeroconf>=0.39; extra == 'all'
32
+ Provides-Extra: discovery
33
+ Requires-Dist: zeroconf>=0.39; extra == 'discovery'
34
+ Provides-Extra: numpy
35
+ Requires-Dist: numpy>=1.22; extra == 'numpy'
36
+ Provides-Extra: video
37
+ Requires-Dist: av>=10; extra == 'video'
38
+ Requires-Dist: numpy>=1.22; extra == 'video'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # irtsp
42
+
43
+ **Typed, SI-unit, one-shared-clock sensor streams from an iPhone.**
44
+
45
+ [iRTSP](https://ryanrudes.github.io/irtsp-support/) turns an iPhone into a streaming
46
+ sensor rig: RTSP video plus side channels for fused IMU, GNSS, compass heading,
47
+ barometric altitude, ARKit 6-DOF pose, camera intrinsics, and LiDAR metric depth.
48
+ This library is the Python client. Every sample arrives as a small, frozen,
49
+ pattern-matchable dataclass in SI units, carrying two timestamps on a **single clock
50
+ anchor captured once per session** — the same anchor that drives the video's RTP/RTCP
51
+ timeline — so video and odometry align with **no time offset to estimate**. The core
52
+ is pure stdlib, Python ≥ 3.10.
53
+
54
+ ## Install
55
+
56
+ | Command | What you get |
57
+ |---|---|
58
+ | `pip install irtsp` | The core client — odometry + depth channels, all record types. Zero dependencies. |
59
+ | `pip install "irtsp[numpy]"` | `DepthFrame.meters` arrays and `point_cloud()` back-projection. |
60
+ | `pip install "irtsp[discovery]"` | `irtsp.discover()` — find phones over Bonjour/mDNS (zeroconf). |
61
+ | `pip install "irtsp[video]"` | Decoded video frames + `synced()` bundles (PyAV + numpy). **Experimental.** |
62
+ | `pip install "irtsp[all]"` | Everything above. |
63
+
64
+ ## Quickstart
65
+
66
+ ```python
67
+ import irtsp
68
+
69
+ with irtsp.connect("192.168.1.24") as phone: # or "ryans-iphone.local"
70
+ for imu in phone.imu:
71
+ print(imu.gyro, imu.accel) # rad/s, m/s²
72
+ ```
73
+
74
+ Start streaming in the iRTSP app, point `connect()` at the phone's address, iterate.
75
+ Records are regular frozen dataclasses — unpack them, stick them in a queue, feed them
76
+ to your filter.
77
+
78
+ ## Pattern-match the whole odometry channel
79
+
80
+ `phone.odometry` yields every record in arrival order (including depth frames when the
81
+ depth channel is open), and every record type supports structural pattern matching:
82
+
83
+ ```python
84
+ import irtsp
85
+
86
+ with irtsp.connect("192.168.1.24", depth=True) as phone:
87
+ for rec in phone.odometry:
88
+ match rec:
89
+ case irtsp.IMU(gyro=g, accel=a):
90
+ propagate(g, a, rec.host_ts)
91
+ case irtsp.Pose(position=p, tracking=irtsp.Tracking.NORMAL):
92
+ update(p, rec.unix_ts)
93
+ case irtsp.GNSS(lat=lat, lon=lon) as fix if fix.h_accuracy is not None:
94
+ fuse_gps(lat, lon, fix.h_accuracy)
95
+ case irtsp.DepthFrame() as d:
96
+ enqueue_depth(d)
97
+ ```
98
+
99
+ Unknown record types from a newer app version arrive as `irtsp.Unknown` (with the raw
100
+ payload bytes) instead of raising — old clients keep working.
101
+
102
+ ## The streams
103
+
104
+ Each property on the session (`phone.imu`, `phone.gnss`, …) is a **fresh, independent
105
+ iterator** with its own buffer — create as many as you like, they don't compete.
106
+
107
+ | Record | Stream | Rate | SI fields | Wire-native view |
108
+ |---|---|---|---|---|
109
+ | `IMU` | `phone.imu` | ≤ ~100 Hz | `gyro` rad/s · `accel` m/s² (gravity **included**; face-up at rest ≈ (0, 0, −9.81)) · `quat` (x, y, z, w), body→world | `accel_g` |
110
+ | `RawGyro` / `RawAccel` | `phone.raw_gyro` / `phone.raw_accel` | raw sensor mode | rad/s · m/s² | `accel_g` |
111
+ | `Intrinsics` | `phone.intrinsics` | on zoom/lens change | `fx fy cx cy` px at `width×height` · `matrix` (3×3 K) · `scaled()` | — |
112
+ | `GNSS` | `phone.gnss` | ~1 Hz | `lat`/`lon` deg · `altitude` m · `speed` m/s · `h_accuracy`/`v_accuracy` m · `course_deg` | `course_rad` |
113
+ | `Altitude` | `phone.altitude` | ~1 Hz | `relative_altitude` m · `pressure` **Pa** | `pressure_kpa`, `pressure_hpa` |
114
+ | `Heading` | `phone.heading` | event-driven | `true_deg` · `magnetic_deg` · `accuracy_deg` | `true_rad`, `magnetic_rad` |
115
+ | `Pose` | `phone.pose` | ~60 Hz (AR mode) | `position` m (gravity-aligned world frame) · `orientation` quat · `tracking` | — |
116
+ | `DepthFrame` | `phone.depth` | ≤ 30 Hz | half-float **meters**; `meters` (ndarray), `at(x, y)`, `point_cloud(K)` | — |
117
+
118
+ Unit conventions, in one breath: SI everywhere by default — the wire's g-units become
119
+ m/s² (× 9.80665) and its kPa becomes Pa at decode time, with the wire-native value
120
+ always one property away. Angles conventionally spoken in degrees (lat/lon, compass,
121
+ GNSS course) stay degrees under explicit `*_deg` names, with `*_rad` properties.
122
+ Fields CoreLocation marks invalid (negative on the wire) decode to `None`, and an
123
+ attitude-off session's zeroed quaternion slots decode to `quat=None` — no sentinel
124
+ values ever reach your code.
125
+
126
+ Every record also carries `host_ts`, `unix_ts`, `seq`, `gap`, and a `time` property
127
+ (`unix_ts` as an aware UTC `datetime`). More on the two clocks below.
128
+
129
+ ## Discovery
130
+
131
+ ```python
132
+ import irtsp # pip install "irtsp[discovery]"
133
+
134
+ for device in irtsp.discover():
135
+ print(device.name, device.host, device.ports)
136
+ # iPhone 192.168.1.24 {'video': 8554, 'imu': 8555, 'depth': 8556}
137
+
138
+ phone = irtsp.discover()[0].connect(depth=True)
139
+ ```
140
+
141
+ Only devices advertising the iRTSP odometry service (`_irtsp-imu._tcp`) are returned,
142
+ so a random RTSP camera on your network won't show up. No zeroconf installed? Just
143
+ connect by IP.
144
+
145
+ ## Callbacks and `run()`
146
+
147
+ If you'd rather push than pull:
148
+
149
+ ```python
150
+ import irtsp
151
+
152
+ phone = irtsp.connect("192.168.1.24", reconnect=True)
153
+ phone.on(irtsp.GNSS, lambda fix: print(f"{fix.lat:.6f}, {fix.lon:.6f}"))
154
+ phone.on((irtsp.IMU, irtsp.Pose), sink.write)
155
+ phone.run() # blocks until the session closes; Ctrl-C exits cleanly
156
+ ```
157
+
158
+ Callbacks run on the reader thread, so keep them quick; a callback that raises is
159
+ logged and never kills the reader. `phone.latest(irtsp.Intrinsics, wait=2.0)` gives
160
+ you the most recent record of a type — handy for intrinsics, which the server replays
161
+ to late joiners on connect.
162
+
163
+ ## asyncio
164
+
165
+ The async client is a native asyncio implementation (not a thread wrapper) with the
166
+ same shapes:
167
+
168
+ ```python
169
+ import asyncio, irtsp
170
+
171
+ async def main():
172
+ async with await irtsp.connect_async("192.168.1.24") as phone:
173
+ async for imu in phone.imu:
174
+ print(imu.gyro, imu.accel)
175
+
176
+ asyncio.run(main())
177
+ ```
178
+
179
+ ## Depth → numpy → point cloud
180
+
181
+ ```python
182
+ import irtsp # pip install "irtsp[numpy]"
183
+
184
+ with irtsp.connect("192.168.1.24", depth=True) as phone:
185
+ K = phone.latest(irtsp.Intrinsics, wait=2.0) # replayed on connect
186
+
187
+ for frame in phone.depth:
188
+ depth = frame.meters # (H, W) float32, meters
189
+ center = frame.at(frame.width // 2, frame.height // 2) # stdlib, no numpy
190
+ pts = frame.point_cloud(K, stride=4) # (N, 3) float32, camera frame
191
+ ```
192
+
193
+ `point_cloud()` accepts intrinsics at any resolution (the stream's intrinsics are for
194
+ the video) and rescales them to the depth map automatically. The camera frame is the
195
+ standard pinhole convention: +X right, +Y down, +Z forward. Non-finite depths are
196
+ dropped.
197
+
198
+ ## Video + `synced()` — EXPERIMENTAL
199
+
200
+ With the `video` extra, the session can decode the RTSP video and hand you each frame
201
+ bundled with the odometry that belongs to it, all on one clock:
202
+
203
+ ```python
204
+ import irtsp # pip install "irtsp[video]"
205
+
206
+ with irtsp.connect("192.168.1.24", video=True, depth=True) as phone:
207
+ for f in phone.synced():
208
+ f.image # (H, W, 3) RGB uint8
209
+ f.timestamp # unix seconds — same axis as every record's unix_ts
210
+ f.imu # IMU records since the previous frame (pre-integration ready)
211
+ f.pose # Pose SLERP-interpolated at f.timestamp, or None
212
+ f.depth # nearest DepthFrame within tolerance, or None
213
+ f.intrinsics # latest camera matrix
214
+ f.approx_clock # ← read the note below
215
+ ```
216
+
217
+ **The honest note.** Frame times are exact only when FFmpeg exposes the RTCP
218
+ wall-clock anchor (`start_time_realtime`); iRTSP builds its RTCP Sender Reports from
219
+ the same anchor as the odometry, so when that value is available the alignment is as
220
+ good as the record timestamps. When FFmpeg *doesn't* expose it, the stream falls back
221
+ to anchoring the first frame at local receive time — alignment is then off by network
222
+ plus decode latency (typically a few tens of ms) and every frame is flagged
223
+ `approx_clock=True`. Check that flag before trusting tight sync, and if you need
224
+ guaranteed-exact video timing, consume the RTSP stream with your own RTCP-aware
225
+ pipeline as described in the
226
+ [integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md) §4.
227
+
228
+ ## How the synchronization works
229
+
230
+ At the start of each streaming session the phone captures **one anchor pair** — a
231
+ host-clock reading (`mach_absolute_time`, seconds) and the Unix wall time at the same
232
+ instant — and *every* stream derives its timestamps from it. That's the whole trick:
233
+ the streams were never on different clocks, so there is no offset to estimate and
234
+ nothing to cross-correlate.
235
+
236
+ Every record carries both axes:
237
+
238
+ - **`host_ts`** — seconds on the phone's monotonic host clock. The same axis as the
239
+ video/audio presentation timestamps, CoreMotion, ARKit, and the depth frames.
240
+ Cleanest for intra-session alignment; not comparable across reboots.
241
+ - **`unix_ts`** — wall-clock seconds, `unix_ts = wall_anchor + (host_ts − host_anchor)`.
242
+ The same axis as the video's RTCP Sender-Report NTP timeline, and comparable across
243
+ machines.
244
+
245
+ The handshake ships both anchors; `phone.clock` is a `StreamClock` that converts
246
+ either way (`to_unix()` / `to_host()`). Because the anchor is frozen at session start,
247
+ no mid-session NTP adjustment will ever warp your timeline. The full derivation —
248
+ 64-byte record layout, depth framing, RTP→wall-time math — is in the
249
+ [integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md).
250
+
251
+ ## Reliability notes
252
+
253
+ - **Slow consumers can't stall capture.** Each iterator/callback subscription has its
254
+ own bounded buffer (default 8192 records); a consumer that falls behind drops its
255
+ *own* oldest records and never affects the reader or other consumers. The count is
256
+ on `stream.dropped`.
257
+ - **Nothing is lost silently.** Each channel carries a wire sequence number; if
258
+ records were lost upstream (or dropped before your consumer attached), the next
259
+ record's `gap` field says how many went missing right before it (`gap=0` means
260
+ none).
261
+ - **Reconnects.** By default a dropped connection closes the session (iterators end,
262
+ `run()` returns). With `connect(..., reconnect=True)` the client redials with
263
+ backoff and re-reads the handshake — picking up fresh clock anchors if the phone
264
+ restarted its stream.
265
+ - **Bad bytes fail loudly.** Connecting to a non-iRTSP port or desyncing raises
266
+ `irtsp.ProtocolError` rather than yielding garbage.
267
+
268
+ ## Links
269
+
270
+ - iRTSP app + support: <https://ryanrudes.github.io/irtsp-support/>
271
+ - Wire protocol & synchronization spec: [INTEGRATION.md](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md)
272
+
273
+ ## License
274
+
275
+ MIT — see [LICENSE](LICENSE).
irtsp-0.1.0/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # irtsp
2
+
3
+ **Typed, SI-unit, one-shared-clock sensor streams from an iPhone.**
4
+
5
+ [iRTSP](https://ryanrudes.github.io/irtsp-support/) turns an iPhone into a streaming
6
+ sensor rig: RTSP video plus side channels for fused IMU, GNSS, compass heading,
7
+ barometric altitude, ARKit 6-DOF pose, camera intrinsics, and LiDAR metric depth.
8
+ This library is the Python client. Every sample arrives as a small, frozen,
9
+ pattern-matchable dataclass in SI units, carrying two timestamps on a **single clock
10
+ anchor captured once per session** — the same anchor that drives the video's RTP/RTCP
11
+ timeline — so video and odometry align with **no time offset to estimate**. The core
12
+ is pure stdlib, Python ≥ 3.10.
13
+
14
+ ## Install
15
+
16
+ | Command | What you get |
17
+ |---|---|
18
+ | `pip install irtsp` | The core client — odometry + depth channels, all record types. Zero dependencies. |
19
+ | `pip install "irtsp[numpy]"` | `DepthFrame.meters` arrays and `point_cloud()` back-projection. |
20
+ | `pip install "irtsp[discovery]"` | `irtsp.discover()` — find phones over Bonjour/mDNS (zeroconf). |
21
+ | `pip install "irtsp[video]"` | Decoded video frames + `synced()` bundles (PyAV + numpy). **Experimental.** |
22
+ | `pip install "irtsp[all]"` | Everything above. |
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ import irtsp
28
+
29
+ with irtsp.connect("192.168.1.24") as phone: # or "ryans-iphone.local"
30
+ for imu in phone.imu:
31
+ print(imu.gyro, imu.accel) # rad/s, m/s²
32
+ ```
33
+
34
+ Start streaming in the iRTSP app, point `connect()` at the phone's address, iterate.
35
+ Records are regular frozen dataclasses — unpack them, stick them in a queue, feed them
36
+ to your filter.
37
+
38
+ ## Pattern-match the whole odometry channel
39
+
40
+ `phone.odometry` yields every record in arrival order (including depth frames when the
41
+ depth channel is open), and every record type supports structural pattern matching:
42
+
43
+ ```python
44
+ import irtsp
45
+
46
+ with irtsp.connect("192.168.1.24", depth=True) as phone:
47
+ for rec in phone.odometry:
48
+ match rec:
49
+ case irtsp.IMU(gyro=g, accel=a):
50
+ propagate(g, a, rec.host_ts)
51
+ case irtsp.Pose(position=p, tracking=irtsp.Tracking.NORMAL):
52
+ update(p, rec.unix_ts)
53
+ case irtsp.GNSS(lat=lat, lon=lon) as fix if fix.h_accuracy is not None:
54
+ fuse_gps(lat, lon, fix.h_accuracy)
55
+ case irtsp.DepthFrame() as d:
56
+ enqueue_depth(d)
57
+ ```
58
+
59
+ Unknown record types from a newer app version arrive as `irtsp.Unknown` (with the raw
60
+ payload bytes) instead of raising — old clients keep working.
61
+
62
+ ## The streams
63
+
64
+ Each property on the session (`phone.imu`, `phone.gnss`, …) is a **fresh, independent
65
+ iterator** with its own buffer — create as many as you like, they don't compete.
66
+
67
+ | Record | Stream | Rate | SI fields | Wire-native view |
68
+ |---|---|---|---|---|
69
+ | `IMU` | `phone.imu` | ≤ ~100 Hz | `gyro` rad/s · `accel` m/s² (gravity **included**; face-up at rest ≈ (0, 0, −9.81)) · `quat` (x, y, z, w), body→world | `accel_g` |
70
+ | `RawGyro` / `RawAccel` | `phone.raw_gyro` / `phone.raw_accel` | raw sensor mode | rad/s · m/s² | `accel_g` |
71
+ | `Intrinsics` | `phone.intrinsics` | on zoom/lens change | `fx fy cx cy` px at `width×height` · `matrix` (3×3 K) · `scaled()` | — |
72
+ | `GNSS` | `phone.gnss` | ~1 Hz | `lat`/`lon` deg · `altitude` m · `speed` m/s · `h_accuracy`/`v_accuracy` m · `course_deg` | `course_rad` |
73
+ | `Altitude` | `phone.altitude` | ~1 Hz | `relative_altitude` m · `pressure` **Pa** | `pressure_kpa`, `pressure_hpa` |
74
+ | `Heading` | `phone.heading` | event-driven | `true_deg` · `magnetic_deg` · `accuracy_deg` | `true_rad`, `magnetic_rad` |
75
+ | `Pose` | `phone.pose` | ~60 Hz (AR mode) | `position` m (gravity-aligned world frame) · `orientation` quat · `tracking` | — |
76
+ | `DepthFrame` | `phone.depth` | ≤ 30 Hz | half-float **meters**; `meters` (ndarray), `at(x, y)`, `point_cloud(K)` | — |
77
+
78
+ Unit conventions, in one breath: SI everywhere by default — the wire's g-units become
79
+ m/s² (× 9.80665) and its kPa becomes Pa at decode time, with the wire-native value
80
+ always one property away. Angles conventionally spoken in degrees (lat/lon, compass,
81
+ GNSS course) stay degrees under explicit `*_deg` names, with `*_rad` properties.
82
+ Fields CoreLocation marks invalid (negative on the wire) decode to `None`, and an
83
+ attitude-off session's zeroed quaternion slots decode to `quat=None` — no sentinel
84
+ values ever reach your code.
85
+
86
+ Every record also carries `host_ts`, `unix_ts`, `seq`, `gap`, and a `time` property
87
+ (`unix_ts` as an aware UTC `datetime`). More on the two clocks below.
88
+
89
+ ## Discovery
90
+
91
+ ```python
92
+ import irtsp # pip install "irtsp[discovery]"
93
+
94
+ for device in irtsp.discover():
95
+ print(device.name, device.host, device.ports)
96
+ # iPhone 192.168.1.24 {'video': 8554, 'imu': 8555, 'depth': 8556}
97
+
98
+ phone = irtsp.discover()[0].connect(depth=True)
99
+ ```
100
+
101
+ Only devices advertising the iRTSP odometry service (`_irtsp-imu._tcp`) are returned,
102
+ so a random RTSP camera on your network won't show up. No zeroconf installed? Just
103
+ connect by IP.
104
+
105
+ ## Callbacks and `run()`
106
+
107
+ If you'd rather push than pull:
108
+
109
+ ```python
110
+ import irtsp
111
+
112
+ phone = irtsp.connect("192.168.1.24", reconnect=True)
113
+ phone.on(irtsp.GNSS, lambda fix: print(f"{fix.lat:.6f}, {fix.lon:.6f}"))
114
+ phone.on((irtsp.IMU, irtsp.Pose), sink.write)
115
+ phone.run() # blocks until the session closes; Ctrl-C exits cleanly
116
+ ```
117
+
118
+ Callbacks run on the reader thread, so keep them quick; a callback that raises is
119
+ logged and never kills the reader. `phone.latest(irtsp.Intrinsics, wait=2.0)` gives
120
+ you the most recent record of a type — handy for intrinsics, which the server replays
121
+ to late joiners on connect.
122
+
123
+ ## asyncio
124
+
125
+ The async client is a native asyncio implementation (not a thread wrapper) with the
126
+ same shapes:
127
+
128
+ ```python
129
+ import asyncio, irtsp
130
+
131
+ async def main():
132
+ async with await irtsp.connect_async("192.168.1.24") as phone:
133
+ async for imu in phone.imu:
134
+ print(imu.gyro, imu.accel)
135
+
136
+ asyncio.run(main())
137
+ ```
138
+
139
+ ## Depth → numpy → point cloud
140
+
141
+ ```python
142
+ import irtsp # pip install "irtsp[numpy]"
143
+
144
+ with irtsp.connect("192.168.1.24", depth=True) as phone:
145
+ K = phone.latest(irtsp.Intrinsics, wait=2.0) # replayed on connect
146
+
147
+ for frame in phone.depth:
148
+ depth = frame.meters # (H, W) float32, meters
149
+ center = frame.at(frame.width // 2, frame.height // 2) # stdlib, no numpy
150
+ pts = frame.point_cloud(K, stride=4) # (N, 3) float32, camera frame
151
+ ```
152
+
153
+ `point_cloud()` accepts intrinsics at any resolution (the stream's intrinsics are for
154
+ the video) and rescales them to the depth map automatically. The camera frame is the
155
+ standard pinhole convention: +X right, +Y down, +Z forward. Non-finite depths are
156
+ dropped.
157
+
158
+ ## Video + `synced()` — EXPERIMENTAL
159
+
160
+ With the `video` extra, the session can decode the RTSP video and hand you each frame
161
+ bundled with the odometry that belongs to it, all on one clock:
162
+
163
+ ```python
164
+ import irtsp # pip install "irtsp[video]"
165
+
166
+ with irtsp.connect("192.168.1.24", video=True, depth=True) as phone:
167
+ for f in phone.synced():
168
+ f.image # (H, W, 3) RGB uint8
169
+ f.timestamp # unix seconds — same axis as every record's unix_ts
170
+ f.imu # IMU records since the previous frame (pre-integration ready)
171
+ f.pose # Pose SLERP-interpolated at f.timestamp, or None
172
+ f.depth # nearest DepthFrame within tolerance, or None
173
+ f.intrinsics # latest camera matrix
174
+ f.approx_clock # ← read the note below
175
+ ```
176
+
177
+ **The honest note.** Frame times are exact only when FFmpeg exposes the RTCP
178
+ wall-clock anchor (`start_time_realtime`); iRTSP builds its RTCP Sender Reports from
179
+ the same anchor as the odometry, so when that value is available the alignment is as
180
+ good as the record timestamps. When FFmpeg *doesn't* expose it, the stream falls back
181
+ to anchoring the first frame at local receive time — alignment is then off by network
182
+ plus decode latency (typically a few tens of ms) and every frame is flagged
183
+ `approx_clock=True`. Check that flag before trusting tight sync, and if you need
184
+ guaranteed-exact video timing, consume the RTSP stream with your own RTCP-aware
185
+ pipeline as described in the
186
+ [integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md) §4.
187
+
188
+ ## How the synchronization works
189
+
190
+ At the start of each streaming session the phone captures **one anchor pair** — a
191
+ host-clock reading (`mach_absolute_time`, seconds) and the Unix wall time at the same
192
+ instant — and *every* stream derives its timestamps from it. That's the whole trick:
193
+ the streams were never on different clocks, so there is no offset to estimate and
194
+ nothing to cross-correlate.
195
+
196
+ Every record carries both axes:
197
+
198
+ - **`host_ts`** — seconds on the phone's monotonic host clock. The same axis as the
199
+ video/audio presentation timestamps, CoreMotion, ARKit, and the depth frames.
200
+ Cleanest for intra-session alignment; not comparable across reboots.
201
+ - **`unix_ts`** — wall-clock seconds, `unix_ts = wall_anchor + (host_ts − host_anchor)`.
202
+ The same axis as the video's RTCP Sender-Report NTP timeline, and comparable across
203
+ machines.
204
+
205
+ The handshake ships both anchors; `phone.clock` is a `StreamClock` that converts
206
+ either way (`to_unix()` / `to_host()`). Because the anchor is frozen at session start,
207
+ no mid-session NTP adjustment will ever warp your timeline. The full derivation —
208
+ 64-byte record layout, depth framing, RTP→wall-time math — is in the
209
+ [integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md).
210
+
211
+ ## Reliability notes
212
+
213
+ - **Slow consumers can't stall capture.** Each iterator/callback subscription has its
214
+ own bounded buffer (default 8192 records); a consumer that falls behind drops its
215
+ *own* oldest records and never affects the reader or other consumers. The count is
216
+ on `stream.dropped`.
217
+ - **Nothing is lost silently.** Each channel carries a wire sequence number; if
218
+ records were lost upstream (or dropped before your consumer attached), the next
219
+ record's `gap` field says how many went missing right before it (`gap=0` means
220
+ none).
221
+ - **Reconnects.** By default a dropped connection closes the session (iterators end,
222
+ `run()` returns). With `connect(..., reconnect=True)` the client redials with
223
+ backoff and re-reads the handshake — picking up fresh clock anchors if the phone
224
+ restarted its stream.
225
+ - **Bad bytes fail loudly.** Connecting to a non-iRTSP port or desyncing raises
226
+ `irtsp.ProtocolError` rather than yielding garbage.
227
+
228
+ ## Links
229
+
230
+ - iRTSP app + support: <https://ryanrudes.github.io/irtsp-support/>
231
+ - Wire protocol & synchronization spec: [INTEGRATION.md](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md)
232
+
233
+ ## License
234
+
235
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ # irtsp examples
2
+
3
+ Runnable scripts, smallest first. Each takes the phone's hostname or IP as
4
+ its first argument (find it with `irtsp.discover()` or in the iRTSP app) and
5
+ exits cleanly on Ctrl-C.
6
+
7
+ | Script | What it shows | Needs |
8
+ | --- | --- | --- |
9
+ | [`print_all.py`](print_all.py) | every record type, pattern-matched and printed; wire gaps (`record.gap`) and consumer drops (`stream.dropped`) | stdlib |
10
+ | [`imu_to_csv.py`](imu_to_csv.py) | the IMU stream to CSV (SI units) with a `--seconds` duration | stdlib |
11
+ | [`live_pose_trace.py`](live_pose_trace.py) | callbacks + `Session.run()`: distance travelled and current position at 1 Hz | stdlib |
12
+ | [`depth_snapshot.py`](depth_snapshot.py) | one LiDAR depth frame to PNG/`.npy`, plus the point-cloud shape | numpy (matplotlib optional) |
13
+
14
+ ```bash
15
+ python print_all.py 192.168.1.24 --depth
16
+ python imu_to_csv.py ryans-iphone.local --seconds 30 -o imu.csv
17
+ python live_pose_trace.py 192.168.1.24
18
+ python depth_snapshot.py 192.168.1.24 -o depth
19
+ ```