opendevice 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.
Files changed (69) hide show
  1. opendevice-0.1.0/.gitignore +31 -0
  2. opendevice-0.1.0/LICENSE +21 -0
  3. opendevice-0.1.0/PKG-INFO +336 -0
  4. opendevice-0.1.0/README.md +305 -0
  5. opendevice-0.1.0/README.zh-CN.md +290 -0
  6. opendevice-0.1.0/opendevice/__init__.py +45 -0
  7. opendevice-0.1.0/opendevice/__main__.py +6 -0
  8. opendevice-0.1.0/opendevice/ax/__init__.py +58 -0
  9. opendevice-0.1.0/opendevice/ax/ax.py +492 -0
  10. opendevice-0.1.0/opendevice/ax/dtx.py +502 -0
  11. opendevice-0.1.0/opendevice/cli/__init__.py +141 -0
  12. opendevice-0.1.0/opendevice/cli/apps.py +303 -0
  13. opendevice-0.1.0/opendevice/cli/commands.py +346 -0
  14. opendevice-0.1.0/opendevice/cli/common.py +140 -0
  15. opendevice-0.1.0/opendevice/cli/device.py +158 -0
  16. opendevice-0.1.0/opendevice/cli/media.py +426 -0
  17. opendevice-0.1.0/opendevice/cli/system.py +200 -0
  18. opendevice-0.1.0/opendevice/cli/wireless.py +116 -0
  19. opendevice-0.1.0/opendevice/core/__init__.py +26 -0
  20. opendevice-0.1.0/opendevice/core/console.py +34 -0
  21. opendevice-0.1.0/opendevice/core/errors.py +45 -0
  22. opendevice-0.1.0/opendevice/core/lock.py +256 -0
  23. opendevice-0.1.0/opendevice/core/log.py +130 -0
  24. opendevice-0.1.0/opendevice/core/output.py +91 -0
  25. opendevice-0.1.0/opendevice/core/paths.py +124 -0
  26. opendevice-0.1.0/opendevice/core/plistio.py +45 -0
  27. opendevice-0.1.0/opendevice/input/__init__.py +24 -0
  28. opendevice-0.1.0/opendevice/input/hid.py +428 -0
  29. opendevice-0.1.0/opendevice/input/pasteboard.py +92 -0
  30. opendevice-0.1.0/opendevice/media/__init__.py +42 -0
  31. opendevice-0.1.0/opendevice/media/media.py +173 -0
  32. opendevice-0.1.0/opendevice/media/rtp.py +239 -0
  33. opendevice-0.1.0/opendevice/media/stream.py +160 -0
  34. opendevice-0.1.0/opendevice/py.typed +0 -0
  35. opendevice-0.1.0/opendevice/remote/__init__.py +41 -0
  36. opendevice-0.1.0/opendevice/remote/coredevice.py +323 -0
  37. opendevice-0.1.0/opendevice/remote/http2.py +79 -0
  38. opendevice-0.1.0/opendevice/remote/ipstack.py +368 -0
  39. opendevice-0.1.0/opendevice/remote/remotexpc.py +114 -0
  40. opendevice-0.1.0/opendevice/remote/rsd.py +120 -0
  41. opendevice-0.1.0/opendevice/remote/tunnel.py +251 -0
  42. opendevice-0.1.0/opendevice/remote/xpc.py +206 -0
  43. opendevice-0.1.0/opendevice/services/__init__.py +23 -0
  44. opendevice-0.1.0/opendevice/services/afc.py +356 -0
  45. opendevice-0.1.0/opendevice/services/amfi.py +111 -0
  46. opendevice-0.1.0/opendevice/services/base.py +72 -0
  47. opendevice-0.1.0/opendevice/services/diagnostics.py +68 -0
  48. opendevice-0.1.0/opendevice/services/installation.py +190 -0
  49. opendevice-0.1.0/opendevice/services/mounter.py +761 -0
  50. opendevice-0.1.0/opendevice/services/syslog.py +88 -0
  51. opendevice-0.1.0/opendevice/services/tss.py +209 -0
  52. opendevice-0.1.0/opendevice/session/__init__.py +16 -0
  53. opendevice-0.1.0/opendevice/session/controls.py +123 -0
  54. opendevice-0.1.0/opendevice/session/display.py +99 -0
  55. opendevice-0.1.0/opendevice/session/link.py +228 -0
  56. opendevice-0.1.0/opendevice/session/services.py +126 -0
  57. opendevice-0.1.0/opendevice/session/tree.py +61 -0
  58. opendevice-0.1.0/opendevice/session/video.py +240 -0
  59. opendevice-0.1.0/opendevice/transport/__init__.py +16 -0
  60. opendevice-0.1.0/opendevice/transport/devices.py +140 -0
  61. opendevice-0.1.0/opendevice/transport/lockdown.py +112 -0
  62. opendevice-0.1.0/opendevice/transport/pairing.py +149 -0
  63. opendevice-0.1.0/opendevice/transport/service.py +128 -0
  64. opendevice-0.1.0/opendevice/transport/usbmux.py +181 -0
  65. opendevice-0.1.0/opendevice/wireless/__init__.py +19 -0
  66. opendevice-0.1.0/opendevice/wireless/rrp.py +757 -0
  67. opendevice-0.1.0/opendevice/wireless/wifi.py +188 -0
  68. opendevice-0.1.0/pyproject.toml +70 -0
  69. opendevice-0.1.0/requirements.txt +15 -0
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Test / typing / lint caches
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+
18
+ # Scratch files left by tools
19
+ *.log
20
+
21
+ # Developer disk images: fetched at runtime, never committed
22
+ *.dmg
23
+ *.trustcache
24
+
25
+ # Editors / OS
26
+ .vscode/
27
+ .idea/
28
+ *.swp
29
+ .DS_Store
30
+ Thumbs.db
31
+ .python-version
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyrilchan
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.
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: opendevice
3
+ Version: 0.1.0
4
+ Summary: Lightweight iOS device framework: USB/Wi-Fi link, H.265 streaming, input injection and the accessibility tree
5
+ Project-URL: Homepage, https://github.com/oops-workplace/opendevice
6
+ Project-URL: Repository, https://github.com/oops-workplace/opendevice
7
+ Project-URL: Issues, https://github.com/oops-workplace/opendevice/issues
8
+ Author: cyrilchan
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: accessibility,automation,coredevice,hevc,hid,ios,iphone,lockdownd,remote-pairing,remote-xpc,usbmux
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Hardware
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == 'test'
25
+ Provides-Extra: wifi
26
+ Requires-Dist: cryptography>=42; extra == 'wifi'
27
+ Requires-Dist: opack2>=0.0.1; extra == 'wifi'
28
+ Requires-Dist: srptools>=1.0; extra == 'wifi'
29
+ Requires-Dist: zeroconf>=0.131; extra == 'wifi'
30
+ Description-Content-Type: text/markdown
31
+
32
+ <div align="center">
33
+
34
+ # opendevice
35
+
36
+ English · [简体中文](https://github.com/oops-workplace/opendevice/blob/master/README.zh-CN.md)
37
+
38
+ [![PyPI version](https://img.shields.io/pypi/v/opendevice.svg)](https://pypi.org/project/opendevice/)
39
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/opendevice.svg)](https://pypi.org/project/opendevice/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT)
41
+
42
+ </div>
43
+
44
+ A **lightweight** iOS device framework: native screen mirroring, touch and keyboard
45
+ input, and a control tree with coordinates — **UI automation without installing WDA**.
46
+
47
+ > **Adapted for iOS 27 only, use with caution.** Whether other versions work depends
48
+ > on the protocol; nothing else has been verified.
49
+
50
+ > **Accessibility coverage varies by app.** Apps that draw their UI without exposing it
51
+ > through the accessibility tree cannot be read.
52
+
53
+ > **Disclaimer.** This is an independent, third-party project. It is not affiliated
54
+ > with, endorsed by, or sponsored by Apple Inc.
55
+
56
+ ## Contents
57
+
58
+ - [Demo](#demo)
59
+ - [Install](#install)
60
+ - [Commands](#commands)
61
+ - [Device](#device) · [UI](#ui) · [Screen](#screen) · [Apps and files](#apps-and-files) · [Wireless](#wireless) · [System](#system)
62
+ - [Using it as a library](#using-it-as-a-library)
63
+
64
+ ---
65
+
66
+ ## Demo
67
+
68
+ <img src="https://raw.githubusercontent.com/oops-workplace/opendevice/master/examples/webui/screenshots/demo.png" alt="webui demo" width="880">
69
+
70
+ A single-page browser UI that ties these together: live screen, touch, control-tree
71
+ coordinates, hardware keys, text input, USB↔Wi-Fi hot switching. The example
72
+ lives in the GitHub repository only and is not part of any PyPI distribution:
73
+
74
+ ```bash
75
+ git clone https://github.com/oops-workplace/opendevice
76
+ cd opendevice
77
+ python examples/webui/run.py -d <udid> --port 8800
78
+ ```
79
+
80
+ Then open <http://127.0.0.1:8800/>. USB mode needs no installation and runs the
81
+ opendevice copy inside the repository; Wi-Fi mode (`--tunnel network`) requires
82
+ `opendevice[wifi]` and Python 3.13+.
83
+
84
+ Two prerequisites are required: **developer mode on** and **a personalized image
85
+ mounted**. Without either, the screen, touch and control tree are unavailable. Both
86
+ can be done from the command line:
87
+
88
+ ```bash
89
+ opendevice devmode enable # turn on developer mode (the device reboots, then it is confirmed)
90
+ opendevice mounter mount # mount the personalized image (normally done automatically on connect)
91
+ ```
92
+
93
+ `opendevice devmode status` and `opendevice mounter status` show the current state.
94
+
95
+ `webui` **is not an opendevice command** — it is that example's own entry point
96
+ (`run.py`), so its flags are its own (`--port` / `--bind` / `--tunnel` /
97
+ `--dump-h265` …). Run it with `--help`.
98
+
99
+ ---
100
+
101
+ ## Install
102
+
103
+ ```bash
104
+ pip install opendevice # wired mode (USB, zero third-party dependencies)
105
+ pip install "opendevice[wifi]" # plus wireless mode (Wi-Fi, bonjour + RemotePairing)
106
+ ```
107
+
108
+ Requirements:
109
+
110
+ * **Wired mode (USB)**: Python **3.9+**, only iTunes on the host side.
111
+ * **Wireless mode (Wi-Fi)**: Python **3.13+**, plus the dependencies in `opendevice[wifi]`.
112
+ * Both modes have **identical capabilities**; wireless only swaps the link.
113
+ **Screen mirroring and touch injection require iOS 27** (the only version-gated
114
+ piece); everything else (lockdown / AFC / install / apps / diagnostics / logs /
115
+ screenshot / developer mode) works on older systems per the protocol.
116
+
117
+ ---
118
+
119
+ ## Commands
120
+
121
+ Use `opendevice` once installed; `python -m opendevice` is equivalent without an
122
+ install. `opendevice --help` shows the grouped quick reference,
123
+ `opendevice <command> --help` shows every flag for that command.
124
+
125
+ **27 commands, by domain** (the quick reference at the end of `--help` matches this):
126
+
127
+ | Domain | Commands |
128
+ |---|---|
129
+ | Device | `devices` `deviceinfo` |
130
+ | UI | `uitree` `click` `swipe` `key` `type` `hid` `sendtext` `paste` |
131
+ | Apps and files | `applist` `install` `uninstall` `launch` `kill` `ps` `afc` `screenshot` |
132
+ | Screen | `stream` |
133
+ | Wireless | `pairing` `unpairing` `wifi` |
134
+ | System | `devmode` `power` `syslog` `mounter` `paths` |
135
+
136
+ ### Global options
137
+
138
+ | Option | Meaning |
139
+ |---|---|
140
+ | `-d/--device` | target device: full UDID or a unique prefix |
141
+ | `--link auto\|usb\|wifi` | link: `auto` (default) / `usb` / `wifi` |
142
+ | `--force` | take over even when another process holds the device (streaming / syslog) |
143
+ | `--mux HOST:PORT` | usbmux address, `127.0.0.1:27015` on Windows by default |
144
+ | `-v/--verbose`, `--log`, `--log-file` | `-v` for INFO, `-vv` for every packet; `--log` writes to a log file |
145
+
146
+ `-d` is accepted before or after the command. When omitted and exactly one device is
147
+ connected it is used; with several it errors out and lists the candidates. It means
148
+ the same thing on both links.
149
+
150
+ `--link auto` prefers USB: it only falls back to Wi-Fi when no `-d` is given and no
151
+ device is found. `usb` / `wifi` are hard requirements — an unavailable link errors
152
+ out instead of silently switching.
153
+
154
+ ### Device
155
+
156
+ ```bash
157
+ opendevice devices # list devices (the Note column shows who holds each)
158
+ opendevice devices --json --fast # JSON; --fast asks only usbmux, not the product type / OS
159
+ opendevice deviceinfo # basic device info (the common keys)
160
+ opendevice deviceinfo --all # every lockdown key (--json for JSON)
161
+ opendevice deviceinfo --screen # main screen resolution (pixels / points / refresh rate / orientation)
162
+ ```
163
+
164
+ ### UI
165
+
166
+ Control tree and input:
167
+
168
+ ```bash
169
+ opendevice uitree --limit 20 # control tree of the current screen (with point coordinates, ready for click)
170
+ opendevice uitree --filter search # only elements whose text contains this, ancestors kept
171
+ opendevice uitree --json # raw fields of every control
172
+ opendevice uitree --tap search # find -> tap center -> re-fetch and compare
173
+ opendevice uitree --home # press Home first, then fetch
174
+
175
+ opendevice click 200,300 # tap: point coordinates from the tree (normalized internally; 0.5,0.5 works)
176
+ opendevice click search # or a text: locate it and tap its center (--exact / --no-verify)
177
+ opendevice swipe 200,600 200,200 # swipe: start -> end (also accepts point coordinates or 0..1)
178
+ opendevice key home # hardware key: home / power / volumeUp / volumeDown / mute / siri
179
+ opendevice type "abc" # type character by character (ASCII only; use sendtext for other scripts)
180
+ opendevice hid # which HID surfaces this device registered (--raw for raw JSON)
181
+ opendevice paste --set "text" # device pasteboard (--get to read back; --name picks the pasteboard)
182
+ opendevice sendtext "some text" # pasteboard + Cmd+V (--no-paste / --no-approve)
183
+ ```
184
+
185
+ Touch and keyboard share one authentication with the media stream: when no stream is
186
+ open, the command opens one first and then sends.
187
+
188
+ ### Screen
189
+
190
+ ```bash
191
+ opendevice stream --seconds 10 --out screen.h265 # capture 10 seconds
192
+ ```
193
+
194
+ Captures the H.265 Annex-B the device sends and writes it as-is (mpv / VLC play it
195
+ directly), with no remuxing. Without `--out` only statistics are printed; with it the
196
+ bytes go to that path (relative to the current directory; the absolute path is printed
197
+ when done).
198
+
199
+ ```
200
+ tunnel device fde4:7625:93a3::1 <-> host fde4:7625:93a3::2 mtu=16000 RSD port 49814 <- tunnel (stderr)
201
+ video negotiated: 1216x2624 @ 60fps (RTP payload type 100) <- what the device sends
202
+
203
+ duration 10.0 s <- results below (stdout)
204
+ RTP packets 2797 / 3.1 MB
205
+ lost / reordered 0 / 0
206
+ filtered out 10 (non-video packets: audio / control)
207
+ access units 564 (key frames 1) <- access units carried in the Annex-B
208
+ first packet key yes <- no = first frames don't decode, capture a bit longer
209
+ written <output>/screen.h265 (3.1 MB)
210
+
211
+ open it in VLC: <output>/screen.h265
212
+ ```
213
+
214
+ ### Apps and files
215
+
216
+ ```bash
217
+ opendevice applist # your installed apps (--system / --all / --json)
218
+ opendevice install MyApp.ipa # install (auto-staged to /PublicStaging; --developer / --upgrade)
219
+ opendevice uninstall com.example.app
220
+ opendevice launch com.example.app # launch (--arg=-x to pass args; --keep-existing to keep the running one)
221
+ opendevice ps # running app processes (--all / --front / --json)
222
+ opendevice kill com.example.app # stop (bundle id / executable name / pid all work)
223
+ opendevice kill 1234 --signal 15 # send SIGTERM by pid (default 9 = SIGKILL)
224
+ opendevice screenshot --out s.png # one static screenshot
225
+
226
+ opendevice afc ls / # device filesystem (DCIM / Books / PhotoData …)
227
+ opendevice afc ls / --app com.x # an app sandbox (house_arrest): Documents / Library / tmp
228
+ opendevice afc pull /DCIM/100APPLE/IMG_0001.HEIC ./ # device -> host (directories recurse)
229
+ opendevice afc pull /Documents/a.txt ./ --documents # with --app, only its Documents
230
+ opendevice afc push ./a.txt /PublicStaging/a.txt # host -> device
231
+ opendevice afc rm /PublicStaging/a.txt # delete a file
232
+ opendevice afc rm /DCIM/100APPLE -r # delete including contents (directories need -r)
233
+ ```
234
+
235
+ `afc`'s four actions are `ls` / `pull` (device to host) / `push` (host to device) /
236
+ `rm`, mapping one-to-one to the Python API's `Afc.listdir` / `pull` / `push` / `remove`.
237
+
238
+ `--app` goes through house_arrest: only open to developer-signed apps (with
239
+ `get-task-allow`); App Store apps are rejected (`InstallationLookupFailed`).
240
+ `--documents` additionally requires the app to declare `UIFileSharingEnabled`.
241
+
242
+ ### Wireless
243
+
244
+ ```bash
245
+ opendevice pairing # one-time: establish remote pairing over USB (--force to rebuild)
246
+ opendevice unpairing # the reverse: delete the local record (--all; pick one of many with -d)
247
+ opendevice wifi --list # devices advertising on the LAN
248
+ opendevice wifi --seconds 8 # the full Wi-Fi link (0 = tunnel only, no stream)
249
+ opendevice wifi --host 192.168.1.5 # specify an address; without it, discover via bonjour
250
+ opendevice wifi --identifier UUID # pick by the bonjour-advertised identifier when several are present
251
+ ```
252
+
253
+ ### System
254
+
255
+ ```bash
256
+ opendevice devmode status # also reveal / enable / accept
257
+ opendevice power info # all diagnostics (--json for JSON)
258
+ opendevice power ioreg --class X # IORegistry (also narrow with --name / --plane)
259
+ opendevice power battery # battery and power
260
+ opendevice power restart # reboot the device
261
+ opendevice power shutdown # shut down
262
+ opendevice power sleep # screen off
263
+ opendevice syslog --seconds 10 # system log (--filter to narrow; a long task that holds the device)
264
+
265
+ opendevice mounter list # image entries (the mounted one is the personalized DDI)
266
+ opendevice mounter images # cached images and which one works for this device
267
+ opendevice mounter status # whether developer mode is on
268
+ opendevice mounter lookup --type Personalized # signature of this type (--type Developer for the old style)
269
+ opendevice mounter nonce # the device nonce needed for a personalized image
270
+ opendevice mounter fetch <build> # fetch an image into the cache (default source: the opendevice-ddi mirror)
271
+ opendevice mounter unmount # unmount the image (--path for the mount point)
272
+ opendevice mounter mount # mount manually (completed automatically on connect when not mounted)
273
+
274
+ opendevice paths # where the data directory lives (--json for JSON)
275
+ ```
276
+
277
+ Developer images are downloaded automatically: the cache is used when it has one, with no network;
278
+ otherwise `mounter mount` and the mount on connect fetch one first (`--url` / `OPENDEVICE_DDI_URL` changes the source, `--no-fetch` reads the cache only).
279
+
280
+ `stream` and `syslog` are **long tasks**: they hold the device (cross-process lock), so a
281
+ second long task on the same device is blocked (`--force` takes over). Short commands
282
+ like `deviceinfo` / `applist` / `afc` / `screenshot` run concurrently as usual.
283
+
284
+ ---
285
+
286
+ ## Using it as a library
287
+
288
+ The top level is `DeviceSession` — one object per device:
289
+
290
+ ```python
291
+ from opendevice.session import DeviceSession
292
+
293
+ with DeviceSession() as device: # no udid: use the single connected device
294
+ device.start_stream(on_access_unit=lambda au, key: None)
295
+ device.tap(0.5, 0.5) # normalized coordinates
296
+ device.press_button("home")
297
+ device.send_text("some text") # pasteboard + Cmd+V
298
+ for element in device.elements():
299
+ print(element.text, element.center) # AXElement: text / role / frame / center
300
+
301
+ device.install("MyApp.ipa", on_progress=lambda p, s: print(p, s))
302
+ print([app["CFBundleIdentifier"] for app in device.apps()])
303
+ device.launch("com.example.app")
304
+ device.screenshot("shot.png") # static image, no stream needed
305
+ device.syslog(seconds=5, on_line=print)
306
+ with device.afc() as files: # filesystem
307
+ files.pull("/Documents/db.sqlite", "./db.sqlite")
308
+ device.restart()
309
+ ```
310
+
311
+ To reach the protocol layer directly:
312
+
313
+ ```python
314
+ from opendevice import LockdownClient
315
+ from opendevice.remote.coredevice import CoreDeviceClient
316
+ from opendevice.remote.tunnel import open_rsd
317
+
318
+ with LockdownClient() as lockdown: # auto StartSession (incl. TLS)
319
+ print(lockdown.get_value(None, "DeviceName")) # get_value(domain, key); a single arg is the domain
320
+ print(lockdown.all_values()["ProductVersion"])
321
+
322
+ handle = open_rsd() # tunnel + RSD in one call, it owns the close order
323
+ print(len(handle.rsd.services), "services")
324
+
325
+ with CoreDeviceClient(handle.rsd, "com.apple.coredevice.deviceinfo") as core:
326
+ print(core.get_device_info())
327
+ handle.close()
328
+ ```
329
+
330
+ The Wi-Fi link just swaps the tunnel; everything above `RSD` is identical:
331
+
332
+ ```python
333
+ from opendevice.remote.tunnel import open_rsd
334
+
335
+ handle = open_rsd(backend="network") # equivalent to open_wifi_rsd(), plus a cleanup handle
336
+ ```