adbuart 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,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ['3.10', '3.11', '3.12', '3.13']
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install package
21
+ run: python -m pip install -e '.[dev]'
22
+ - name: Run tests
23
+ run: pytest -q
24
+ - name: Build package
25
+ run: python -m build
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: '3.12'
22
+ - name: Install build backend
23
+ run: python -m pip install build
24
+ - name: Build package
25
+ run: python -m build
26
+ - name: Publish package
27
+ uses: pypa/gh-action-pypi-publish@release/v1
adbuart-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XuNeo
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.
adbuart-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: adbuart
3
+ Version: 0.1.0
4
+ Summary: Expose an Android device UART as a local PTY over adb.
5
+ Project-URL: Homepage, https://github.com/XuNeo/adbuart
6
+ Project-URL: Repository, https://github.com/XuNeo/adbuart
7
+ Project-URL: Issues, https://github.com/XuNeo/adbuart/issues
8
+ Author: XuNeo
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: adb,nuttx,pty,rtos,serial,uart
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Embedded Systems
23
+ Classifier: Topic :: System :: Hardware
24
+ Classifier: Topic :: Terminals :: Serial
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # adbuart
32
+
33
+ Expose an Android-device UART as a local PTY over `adb`, so host serial tools such as `minicom`, `picocom`, or pyserial can talk to it like a normal serial port.
34
+
35
+ This is useful when a device has no host-side USB serial adapter and the RTOS console is reachable only through an Android UART device, for example `/dev/ttyHS1` on G2 Air style targets.
36
+
37
+ ## Requirements
38
+
39
+ - Linux or another POSIX-like host with Python 3.10+
40
+ - `adb` installed and available in `PATH`
41
+ - An adb-connected Android device where `adb root` is allowed
42
+ - Target-side UART device access, defaulting to `/dev/ttyHS1`
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ python3 -m pip install adbuart
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ Start the bridge in one terminal:
53
+
54
+ ```bash
55
+ adbuart -n g2air
56
+ ```
57
+
58
+ With multiple adb devices attached, pass the adb serial explicitly:
59
+
60
+ ```bash
61
+ adbuart -s d7a6340b -n g2air
62
+ ```
63
+
64
+ The command creates `/tmp/g2air` and keeps running in the foreground. It prints a minicom command when the PTY is ready.
65
+
66
+ Open the PTY from another terminal:
67
+
68
+ ```bash
69
+ minicom -D /tmp/g2air -b 2000000
70
+ # or
71
+ python3 -m serial.tools.miniterm /tmp/g2air 2000000
72
+ ```
73
+
74
+ Stop the bridge with `Ctrl-C` in the bridge terminal. Clean shutdown restores RTOS logging when it was enabled before startup.
75
+
76
+ ## Options
77
+
78
+ Run:
79
+
80
+ ```bash
81
+ adbuart --help
82
+ ```
83
+
84
+ Important options:
85
+
86
+ - `-n, --name` creates `/tmp/<name>` and is required.
87
+ - `-s, --serial` selects an adb device. If omitted, exactly one device must be connected.
88
+ - `--remote-tty` selects the target UART device path. Default: `/dev/ttyHS1`.
89
+ - `--baud` sets the initial remote UART baud rate. Default: `2000000`.
90
+ - `--cmd-before` runs a remote shell command before bridging. Repeatable.
91
+ - `--cmd-after` runs a remote shell command during teardown. Repeatable.
92
+
93
+ By default the tool sets `persist.rtoslogd.uart` to `0` before bridging so Android log collection releases the UART. During teardown it restores the property to `1` only when it was `1` at startup.
94
+
95
+ ## Operating notes
96
+
97
+ - The bridge owns the target UART exclusively while it is running. Stop it before flashing, resetting, or opening the same UART through another tool.
98
+ - The local node is protected with a lock file, but unrelated tools can still collide at the device UART level.
99
+ - Avoid `kill -9`; it skips teardown and can leave RTOS logging disabled or a stale `/tmp/<name>` symlink.
100
+ - Serial disconnects during target reset are normal. Restart the bridge after the adb UART stream closes.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ python3 -m pip install -e '.[dev]'
106
+ pytest -q
107
+ python3 -m build
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,82 @@
1
+ # adbuart
2
+
3
+ Expose an Android-device UART as a local PTY over `adb`, so host serial tools such as `minicom`, `picocom`, or pyserial can talk to it like a normal serial port.
4
+
5
+ This is useful when a device has no host-side USB serial adapter and the RTOS console is reachable only through an Android UART device, for example `/dev/ttyHS1` on G2 Air style targets.
6
+
7
+ ## Requirements
8
+
9
+ - Linux or another POSIX-like host with Python 3.10+
10
+ - `adb` installed and available in `PATH`
11
+ - An adb-connected Android device where `adb root` is allowed
12
+ - Target-side UART device access, defaulting to `/dev/ttyHS1`
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ python3 -m pip install adbuart
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Start the bridge in one terminal:
23
+
24
+ ```bash
25
+ adbuart -n g2air
26
+ ```
27
+
28
+ With multiple adb devices attached, pass the adb serial explicitly:
29
+
30
+ ```bash
31
+ adbuart -s d7a6340b -n g2air
32
+ ```
33
+
34
+ The command creates `/tmp/g2air` and keeps running in the foreground. It prints a minicom command when the PTY is ready.
35
+
36
+ Open the PTY from another terminal:
37
+
38
+ ```bash
39
+ minicom -D /tmp/g2air -b 2000000
40
+ # or
41
+ python3 -m serial.tools.miniterm /tmp/g2air 2000000
42
+ ```
43
+
44
+ Stop the bridge with `Ctrl-C` in the bridge terminal. Clean shutdown restores RTOS logging when it was enabled before startup.
45
+
46
+ ## Options
47
+
48
+ Run:
49
+
50
+ ```bash
51
+ adbuart --help
52
+ ```
53
+
54
+ Important options:
55
+
56
+ - `-n, --name` creates `/tmp/<name>` and is required.
57
+ - `-s, --serial` selects an adb device. If omitted, exactly one device must be connected.
58
+ - `--remote-tty` selects the target UART device path. Default: `/dev/ttyHS1`.
59
+ - `--baud` sets the initial remote UART baud rate. Default: `2000000`.
60
+ - `--cmd-before` runs a remote shell command before bridging. Repeatable.
61
+ - `--cmd-after` runs a remote shell command during teardown. Repeatable.
62
+
63
+ By default the tool sets `persist.rtoslogd.uart` to `0` before bridging so Android log collection releases the UART. During teardown it restores the property to `1` only when it was `1` at startup.
64
+
65
+ ## Operating notes
66
+
67
+ - The bridge owns the target UART exclusively while it is running. Stop it before flashing, resetting, or opening the same UART through another tool.
68
+ - The local node is protected with a lock file, but unrelated tools can still collide at the device UART level.
69
+ - Avoid `kill -9`; it skips teardown and can leave RTOS logging disabled or a stale `/tmp/<name>` symlink.
70
+ - Serial disconnects during target reset are normal. Restart the bridge after the adb UART stream closes.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ python3 -m pip install -e '.[dev]'
76
+ pytest -q
77
+ python3 -m build
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "adbuart"
7
+ version = "0.1.0"
8
+ description = "Expose an Android device UART as a local PTY over adb."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "XuNeo" },
14
+ ]
15
+ keywords = ["adb", "uart", "pty", "serial", "nuttx", "rtos"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Embedded Systems",
28
+ "Topic :: System :: Hardware",
29
+ "Topic :: Terminals :: Serial",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "build>=1.2",
36
+ "pytest>=8.0",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/XuNeo/adbuart"
41
+ Repository = "https://github.com/XuNeo/adbuart"
42
+ Issues = "https://github.com/XuNeo/adbuart/issues"
43
+
44
+ [project.scripts]
45
+ adbuart = "adbuart.cli:main"
@@ -0,0 +1,3 @@
1
+ """Expose an Android device UART as a local PTY over adb."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import errno
4
+ import fcntl
5
+ import os
6
+ import pty
7
+ import re
8
+ import select
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import termios
13
+ import time
14
+ import tty
15
+
16
+ DEFAULT_REMOTE_TTY = "/dev/ttyHS1"
17
+ DEFAULT_BAUD = "2000000"
18
+ DEFAULT_CMD_BEFORE = "setprop persist.rtoslogd.uart 0"
19
+ DEFAULT_CMD_AFTER = "setprop persist.rtoslogd.uart 1"
20
+ BAUD_SYNC_POLL_MS = 20
21
+ SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
22
+ BAUD_TO_TERMIOS = {
23
+ name[1:]: value
24
+ for name, value in vars(termios).items()
25
+ if name.startswith("B") and name[1:].isdigit()
26
+ }
27
+ TERMIOS_TO_BAUD = {value: name for name, value in BAUD_TO_TERMIOS.items()}
28
+
29
+
30
+ def warn_stderr(message):
31
+ print(f"warning: {message}", file=sys.stderr)
32
+
33
+
34
+ def validate_name(name: str) -> str:
35
+ if not name or name in (".", ".."):
36
+ raise ValueError("--name must be a non-empty basename")
37
+ if "/" in name or name.strip() != name or not SAFE_NAME_RE.match(name):
38
+ raise ValueError("--name may contain only letters, digits, dot, underscore, and dash")
39
+ return name
40
+
41
+
42
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
43
+ parser = argparse.ArgumentParser(
44
+ description="Expose an Android device UART as a local /tmp/<name> PTY over adb."
45
+ )
46
+ parser.add_argument(
47
+ "-s",
48
+ "--serial",
49
+ help="ADB serial, passed to every adb command with -s; auto-selects when exactly one device is connected",
50
+ )
51
+ parser.add_argument("-n", "--name", required=True, type=validate_name, help="Local node name created as /tmp/<name>")
52
+ parser.add_argument("--remote-tty", default=DEFAULT_REMOTE_TTY, help=f"Remote UART device path (default: {DEFAULT_REMOTE_TTY})")
53
+ parser.add_argument("--baud", default=DEFAULT_BAUD, help=f"Remote UART baud rate for stty (default: {DEFAULT_BAUD})")
54
+ parser.add_argument(
55
+ "--cmd-before",
56
+ action="append",
57
+ help=f"Remote shell command before bridging; repeatable (default: {DEFAULT_CMD_BEFORE!r})",
58
+ )
59
+ parser.add_argument(
60
+ "--cmd-after",
61
+ action="append",
62
+ help=f"Remote shell command during teardown; repeatable (default: {DEFAULT_CMD_AFTER!r} when G2 logging was enabled)",
63
+ )
64
+ return parser.parse_args(argv)
65
+
66
+
67
+ def build_adb_args(serial: str, *args: str) -> list[str]:
68
+ return ["adb", "-s", serial, *args]
69
+
70
+
71
+ def list_adb_device_serials() -> list[str]:
72
+ result = subprocess.run(
73
+ ["adb", "devices"],
74
+ check=True,
75
+ text=True,
76
+ stdout=subprocess.PIPE,
77
+ stderr=subprocess.PIPE,
78
+ )
79
+ serials = []
80
+ for line in result.stdout.splitlines():
81
+ line = line.strip()
82
+ if not line or line.startswith("List of devices attached"):
83
+ continue
84
+ fields = line.split()
85
+ if len(fields) >= 2 and fields[1] == "device":
86
+ serials.append(fields[0])
87
+ return serials
88
+
89
+
90
+ def resolve_serial(serial: str | None) -> str:
91
+ if serial:
92
+ return serial
93
+ try:
94
+ serials = list_adb_device_serials()
95
+ except subprocess.CalledProcessError as exc:
96
+ detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
97
+ raise RuntimeError(f"failed to run adb devices: {detail}") from exc
98
+ if len(serials) == 1:
99
+ return serials[0]
100
+ if not serials:
101
+ raise RuntimeError("no adb devices in state 'device'; pass --serial explicitly")
102
+ raise RuntimeError(
103
+ "multiple adb devices in state 'device' ("
104
+ + ", ".join(serials)
105
+ + "); pass --serial explicitly"
106
+ )
107
+
108
+
109
+ def build_stty_command(remote_tty: str, baud: str) -> str:
110
+ return f"stty -F {remote_tty} {baud} raw -echo -crtscts cs8 -parenb -cstopb min 1 time 0"
111
+
112
+
113
+ def build_remote_command(remote_tty: str = DEFAULT_REMOTE_TTY, baud: str = DEFAULT_BAUD) -> str:
114
+ return (
115
+ f"{build_stty_command(remote_tty, baud)} || exit $?; "
116
+ "trap 'kill \"$rx\" 2>/dev/null; wait \"$rx\" 2>/dev/null' EXIT HUP INT TERM; "
117
+ f"cat {remote_tty} & rx=$!; "
118
+ f"cat > {remote_tty}"
119
+ )
120
+
121
+
122
+ def termios_baud_name(fd):
123
+ speed = termios.tcgetattr(fd)[5]
124
+ return TERMIOS_TO_BAUD.get(speed)
125
+
126
+
127
+ def set_fd_baud(fd, baud: str) -> bool:
128
+ speed = BAUD_TO_TERMIOS.get(str(baud))
129
+ if speed is None:
130
+ return False
131
+ attrs = termios.tcgetattr(fd)
132
+ attrs[4] = speed
133
+ attrs[5] = speed
134
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
135
+ return True
136
+
137
+
138
+ def sync_pty_baud_if_changed(slave_fd, adb, remote_tty, last_baud):
139
+ current_baud = termios_baud_name(slave_fd)
140
+ if current_baud is None or current_baud == last_baud:
141
+ return last_baud
142
+ adb.shell(build_stty_command(remote_tty, current_baud))
143
+ return current_baud
144
+
145
+
146
+ class BaudSynchronizer:
147
+ def __init__(self, slave_fd, adb, remote_tty, initial_baud, poll_ms=BAUD_SYNC_POLL_MS):
148
+ self.slave_fd = slave_fd
149
+ self.adb = adb
150
+ self.remote_tty = remote_tty
151
+ self.last_baud = initial_baud
152
+ self.poll_interval = poll_ms / 1000.0
153
+ self.next_poll = 0.0
154
+
155
+ def sync_if_due(self):
156
+ now = time.monotonic()
157
+ if now < self.next_poll:
158
+ return
159
+ self.next_poll = now + self.poll_interval
160
+ self.last_baud = sync_pty_baud_if_changed(self.slave_fd, self.adb, self.remote_tty, self.last_baud)
161
+
162
+ def sync_now(self):
163
+ self.next_poll = time.monotonic() + self.poll_interval
164
+ self.last_baud = sync_pty_baud_if_changed(self.slave_fd, self.adb, self.remote_tty, self.last_baud)
165
+
166
+
167
+ class BridgePaths:
168
+ def __init__(self, name, tmp_dir="/tmp"):
169
+ safe_name = validate_name(name)
170
+ self.node_path = os.path.join(tmp_dir, safe_name)
171
+ self.lock_path = os.path.join(tmp_dir, safe_name + ".lock")
172
+
173
+
174
+ def create_symlink(node_path, slave_path, lock=None):
175
+ if os.path.lexists(node_path):
176
+ if not os.path.islink(node_path):
177
+ raise FileExistsError(f"refusing to overwrite non-symlink: {node_path}")
178
+ current_target = os.readlink(node_path)
179
+ if os.path.exists(node_path):
180
+ raise FileExistsError(f"refusing to overwrite live symlink: {node_path} -> {current_target}")
181
+ os.unlink(node_path)
182
+ os.symlink(slave_path, node_path)
183
+
184
+
185
+ def remove_symlink_if_owned(node_path, slave_path):
186
+ if not os.path.islink(node_path):
187
+ return
188
+ if os.readlink(node_path) == slave_path:
189
+ os.unlink(node_path)
190
+
191
+
192
+ class LocalLock:
193
+ def __init__(self, lock_path):
194
+ self.lock_path = lock_path
195
+ self.fd = None
196
+
197
+ def acquire(self):
198
+ self.fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
199
+ try:
200
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
201
+ except BlockingIOError as exc:
202
+ os.close(self.fd)
203
+ self.fd = None
204
+ raise RuntimeError(f"lock already held: {self.lock_path}") from exc
205
+
206
+ def is_held(self):
207
+ return self.fd is not None
208
+
209
+ def release(self):
210
+ if self.fd is None:
211
+ return
212
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
213
+ os.close(self.fd)
214
+ self.fd = None
215
+
216
+
217
+ class Adb:
218
+ def __init__(self, serial):
219
+ self.serial = serial
220
+
221
+ def run(self, *args, check=True):
222
+ return subprocess.run(
223
+ build_adb_args(self.serial, *args),
224
+ check=check,
225
+ text=True,
226
+ stdout=subprocess.PIPE,
227
+ stderr=subprocess.PIPE,
228
+ )
229
+
230
+ def shell(self, command, check=True):
231
+ return self.run("shell", command, check=check)
232
+
233
+ def popen_shell_t(self, command):
234
+ return subprocess.Popen(
235
+ build_adb_args(self.serial, "shell", "-T", command),
236
+ stdin=subprocess.PIPE,
237
+ stdout=subprocess.PIPE,
238
+ stderr=subprocess.PIPE,
239
+ )
240
+
241
+
242
+ def run_remote_commands(adb, commands):
243
+ for command in commands:
244
+ adb.shell(command)
245
+
246
+
247
+ def get_rtos_uart_state(adb):
248
+ result = adb.shell("getprop persist.rtoslogd.uart")
249
+ return result.stdout.strip()
250
+
251
+
252
+ def set_rtos_uart_state(adb, state):
253
+ adb.shell(f"setprop persist.rtoslogd.uart {state}")
254
+
255
+
256
+ def restore_rtos_logging(adb, startup_state):
257
+ if startup_state != "1":
258
+ return True
259
+ try:
260
+ set_rtos_uart_state(adb, "1")
261
+ return True
262
+ except Exception as exc:
263
+ warn_stderr(f"failed to restore RTOS logging: {exc}")
264
+ return False
265
+
266
+
267
+ def run_cmd_after(adb, startup_state, commands):
268
+ try:
269
+ if commands is None:
270
+ return restore_rtos_logging(adb, startup_state)
271
+ run_remote_commands(adb, commands)
272
+ return True
273
+ except Exception as exc:
274
+ warn_stderr(f"failed to run teardown commands: {exc}")
275
+ return False
276
+
277
+
278
+ COPY_SIZE = 65536
279
+
280
+
281
+ def read_chunk(fd):
282
+ while True:
283
+ try:
284
+ return os.read(fd, COPY_SIZE)
285
+ except InterruptedError:
286
+ continue
287
+
288
+
289
+ def write_all(fd, data):
290
+ view = memoryview(data)
291
+ offset = 0
292
+ while offset < len(view):
293
+ try:
294
+ written = os.write(fd, view[offset:])
295
+ except InterruptedError:
296
+ continue
297
+ if written == 0:
298
+ raise OSError(errno.EPIPE, "write returned zero")
299
+ offset += written
300
+
301
+
302
+ def copy_available(src_fd, dst_fd):
303
+ data = read_chunk(src_fd)
304
+ if not data:
305
+ raise EOFError("input closed")
306
+ write_all(dst_fd, data)
307
+ return len(data)
308
+
309
+
310
+ def drain_to_eof(src_fd, dst_fd):
311
+ while True:
312
+ data = read_chunk(src_fd)
313
+ if not data:
314
+ return
315
+ write_all(dst_fd, data)
316
+
317
+
318
+ def forward_loop(pty_fd, adb_proc, baud_sync=None):
319
+ adb_stdout_fd = adb_proc.stdout.fileno()
320
+ adb_stdin_fd = adb_proc.stdin.fileno()
321
+ adb_stderr_fd = adb_proc.stderr.fileno()
322
+ poller = select.poll()
323
+ poller.register(pty_fd, select.POLLIN | select.POLLERR | select.POLLHUP)
324
+ poller.register(adb_stdout_fd, select.POLLIN | select.POLLERR | select.POLLHUP)
325
+ poller.register(adb_stderr_fd, select.POLLIN | select.POLLERR | select.POLLHUP)
326
+
327
+ def drain_adb_pipes():
328
+ drain_to_eof(adb_stdout_fd, pty_fd)
329
+ drain_to_eof(adb_stderr_fd, sys.stderr.fileno())
330
+
331
+ while True:
332
+ if adb_proc.poll() is not None:
333
+ drain_adb_pipes()
334
+ return
335
+ if baud_sync is not None:
336
+ baud_sync.sync_if_due()
337
+ events = poller.poll(BAUD_SYNC_POLL_MS if baud_sync is not None else 1000)
338
+ for fd, event in events:
339
+ if adb_proc.poll() is not None:
340
+ drain_adb_pipes()
341
+ return
342
+ if fd == pty_fd:
343
+ if event & select.POLLIN:
344
+ if baud_sync is not None:
345
+ baud_sync.sync_now()
346
+ copy_available(pty_fd, adb_stdin_fd)
347
+ elif event & (select.POLLERR | select.POLLHUP):
348
+ raise EOFError(f"fd closed: {fd}")
349
+ elif fd == adb_stdout_fd:
350
+ if event & select.POLLIN:
351
+ copy_available(adb_stdout_fd, pty_fd)
352
+ elif event & (select.POLLERR | select.POLLHUP):
353
+ raise EOFError(f"fd closed: {fd}")
354
+ elif fd == adb_stderr_fd:
355
+ if event & select.POLLIN:
356
+ copy_available(adb_stderr_fd, sys.stderr.fileno())
357
+ elif event & (select.POLLERR | select.POLLHUP):
358
+ raise EOFError(f"fd closed: {fd}")
359
+
360
+
361
+ class BridgeRuntime:
362
+ def __init__(self, adb, node_path, slave_path, lock, startup_rtos_state, cmd_after=None):
363
+ self.adb = adb
364
+ self.node_path = node_path
365
+ self.slave_path = slave_path
366
+ self.lock = lock
367
+ self.startup_rtos_state = startup_rtos_state
368
+ self.cmd_after = cmd_after
369
+ self.adb_proc = None
370
+ self.pty_fd = None
371
+ self.slave_fd = None
372
+ self.restore_ok = None
373
+ self._torn_down = False
374
+
375
+ def teardown(self, reason):
376
+ if self._torn_down:
377
+ return self.restore_ok
378
+ self._torn_down = True
379
+ if self.adb_proc is not None and self.adb_proc.poll() is None:
380
+ try:
381
+ self.adb_proc.terminate()
382
+ except Exception as exc:
383
+ warn_stderr(f"failed to terminate adb bridge: {exc}")
384
+ try:
385
+ self.adb_proc.wait(timeout=2)
386
+ except subprocess.TimeoutExpired:
387
+ try:
388
+ self.adb_proc.kill()
389
+ except Exception as exc:
390
+ warn_stderr(f"failed to kill adb bridge: {exc}")
391
+ try:
392
+ self.adb_proc.wait(timeout=2)
393
+ except subprocess.TimeoutExpired as exc:
394
+ warn_stderr(f"adb bridge did not exit after kill: {exc}")
395
+ except Exception as exc:
396
+ warn_stderr(f"failed waiting for killed adb bridge: {exc}")
397
+ except Exception as exc:
398
+ warn_stderr(f"failed waiting for adb bridge: {exc}")
399
+ if self.pty_fd is not None:
400
+ try:
401
+ os.close(self.pty_fd)
402
+ except OSError:
403
+ pass
404
+ self.pty_fd = None
405
+ if self.slave_fd is not None:
406
+ try:
407
+ os.close(self.slave_fd)
408
+ except OSError:
409
+ pass
410
+ self.slave_fd = None
411
+ remove_symlink_if_owned(self.node_path, self.slave_path)
412
+ self.restore_ok = run_cmd_after(self.adb, self.startup_rtos_state, self.cmd_after)
413
+ self.lock.release()
414
+ print(f"teardown complete: {reason}", file=sys.stderr)
415
+ return self.restore_ok
416
+
417
+
418
+ _active_runtime = None
419
+
420
+
421
+ def set_raw(fd):
422
+ tty.setraw(fd, when=termios.TCSANOW)
423
+
424
+
425
+ class PrepareRemoteUartError(RuntimeError):
426
+ def __init__(self, message, startup_state):
427
+ super().__init__(message)
428
+ self.startup_state = startup_state
429
+
430
+
431
+ def prepare_remote_uart(adb, cmd_before=None, cmd_after=None):
432
+ adb.run("wait-for-device")
433
+ adb.run("root")
434
+ adb.run("wait-for-device")
435
+ startup_state = ""
436
+ if cmd_after is None:
437
+ startup_state = get_rtos_uart_state(adb)
438
+ try:
439
+ if cmd_before is None:
440
+ set_rtos_uart_state(adb, "0")
441
+ else:
442
+ run_remote_commands(adb, cmd_before)
443
+ except BaseException as exc:
444
+ exc.startup_state = startup_state
445
+ raise
446
+ return startup_state
447
+
448
+
449
+ def _signal_handler(signum, frame):
450
+ if _active_runtime is not None:
451
+ _active_runtime.teardown(f"signal {signum}")
452
+ raise SystemExit(128 + signum)
453
+
454
+
455
+ def install_signal_handlers():
456
+ signal.signal(signal.SIGINT, _signal_handler)
457
+ signal.signal(signal.SIGTERM, _signal_handler)
458
+
459
+
460
+ def _fd_is_tty(fd):
461
+ try:
462
+ return os.isatty(fd)
463
+ except OSError:
464
+ return False
465
+
466
+
467
+ def close_fd_if_owned(fd, owned):
468
+ if not owned or fd is None:
469
+ return
470
+ try:
471
+ os.close(fd)
472
+ except OSError:
473
+ pass
474
+
475
+
476
+ def cleanup_before_runtime(paths, lock, adb, startup_state, cmd_after, master_fd, master_fd_owned, slave_fd, slave_fd_owned, slave_path):
477
+ close_fd_if_owned(master_fd, master_fd_owned)
478
+ close_fd_if_owned(slave_fd, slave_fd_owned)
479
+ if slave_path is not None:
480
+ remove_symlink_if_owned(paths.node_path, slave_path)
481
+ run_cmd_after(adb, startup_state, cmd_after)
482
+ lock.release()
483
+
484
+
485
+ def run_bridge(args, tmp_dir="/tmp"):
486
+ global _active_runtime
487
+ paths = BridgePaths(args.name, tmp_dir=tmp_dir)
488
+ lock = LocalLock(paths.lock_path)
489
+ lock.acquire()
490
+ adb = Adb(args.serial)
491
+ startup_state = ""
492
+ master_fd = None
493
+ master_fd_owned = False
494
+ slave_fd = None
495
+ slave_fd_owned = False
496
+ slave_path = None
497
+ runtime = None
498
+ try:
499
+ startup_state = prepare_remote_uart(adb, args.cmd_before, args.cmd_after)
500
+ master_fd, slave_fd = pty.openpty()
501
+ slave_path = os.ttyname(slave_fd)
502
+ slave_fd_owned = _fd_is_tty(slave_fd)
503
+ master_fd_owned = _fd_is_tty(master_fd)
504
+ if master_fd_owned:
505
+ set_raw(master_fd)
506
+ if slave_fd_owned and not set_fd_baud(slave_fd, args.baud):
507
+ warn_stderr(f"cannot observe local PTY baud {args.baud}; remote baud sync disabled until a supported rate is set")
508
+ create_symlink(paths.node_path, slave_path, lock=lock)
509
+ runtime = BridgeRuntime(adb, paths.node_path, slave_path, lock, startup_state, args.cmd_after)
510
+ runtime.pty_fd = master_fd if master_fd_owned else None
511
+ runtime.slave_fd = slave_fd if slave_fd_owned else None
512
+ baud_sync = BaudSynchronizer(slave_fd, adb, args.remote_tty, args.baud) if slave_fd_owned else None
513
+ runtime.adb_proc = adb.popen_shell_t(build_remote_command(args.remote_tty, args.baud))
514
+ _active_runtime = runtime
515
+ print(f"PTY ready: {paths.node_path}", file=sys.stderr)
516
+ print(f"Target: adb -s {args.serial} {args.remote_tty}", file=sys.stderr)
517
+ print(f"Use another terminal: minicom -D {paths.node_path} -b {args.baud}", file=sys.stderr)
518
+ print("Press Ctrl-C here to teardown.", file=sys.stderr)
519
+ forward_loop(master_fd, runtime.adb_proc, baud_sync=baud_sync)
520
+ adb_returncode = runtime.adb_proc.poll()
521
+ restore_ok = runtime.teardown("forward loop ended")
522
+ if adb_returncode not in (None, 0):
523
+ return 1
524
+ if restore_ok is False:
525
+ return 1
526
+ return 0
527
+ except PrepareRemoteUartError as exc:
528
+ startup_state = exc.startup_state
529
+ run_cmd_after(adb, startup_state, args.cmd_after)
530
+ lock.release()
531
+ print(f"error: {exc}", file=sys.stderr)
532
+ return 1
533
+ except Exception as exc:
534
+ startup_state = getattr(exc, "startup_state", startup_state)
535
+ if runtime is not None:
536
+ runtime.teardown(str(exc))
537
+ else:
538
+ cleanup_before_runtime(
539
+ paths, lock, adb, startup_state, args.cmd_after,
540
+ master_fd, master_fd_owned, slave_fd, slave_fd_owned, slave_path,
541
+ )
542
+ print(f"error: {exc}", file=sys.stderr)
543
+ return 1
544
+ except BaseException as exc:
545
+ startup_state = getattr(exc, "startup_state", startup_state)
546
+ if runtime is not None:
547
+ runtime.teardown(str(exc))
548
+ else:
549
+ cleanup_before_runtime(
550
+ paths, lock, adb, startup_state, args.cmd_after,
551
+ master_fd, master_fd_owned, slave_fd, slave_fd_owned, slave_path,
552
+ )
553
+ raise
554
+ finally:
555
+ _active_runtime = None
556
+
557
+
558
+ def main(argv: list[str] | None = None) -> int:
559
+ install_signal_handlers()
560
+ args = parse_args(argv)
561
+ try:
562
+ args.serial = resolve_serial(args.serial)
563
+ except RuntimeError as exc:
564
+ print(f"error: {exc}", file=sys.stderr)
565
+ return 1
566
+ return run_bridge(args)
567
+
568
+
569
+ if __name__ == "__main__":
570
+ raise SystemExit(main())
@@ -0,0 +1,68 @@
1
+ import argparse
2
+
3
+ import pytest
4
+
5
+ from adbuart import cli
6
+
7
+
8
+ def test_parse_args_keeps_required_name_and_defaults():
9
+ args = cli.parse_args(["-n", "g2air"])
10
+
11
+ assert args.name == "g2air"
12
+ assert args.serial is None
13
+ assert args.remote_tty == cli.DEFAULT_REMOTE_TTY
14
+ assert args.baud == cli.DEFAULT_BAUD
15
+ assert args.cmd_before is None
16
+ assert args.cmd_after is None
17
+
18
+
19
+ def test_parse_args_accepts_existing_options():
20
+ args = cli.parse_args([
21
+ "-s",
22
+ "d7a6340b",
23
+ "-n",
24
+ "g2air",
25
+ "--remote-tty",
26
+ "/dev/ttyHS2",
27
+ "--baud",
28
+ "115200",
29
+ "--cmd-before",
30
+ "echo before",
31
+ "--cmd-before",
32
+ "echo before2",
33
+ "--cmd-after",
34
+ "echo after",
35
+ ])
36
+
37
+ assert args.serial == "d7a6340b"
38
+ assert args.name == "g2air"
39
+ assert args.remote_tty == "/dev/ttyHS2"
40
+ assert args.baud == "115200"
41
+ assert args.cmd_before == ["echo before", "echo before2"]
42
+ assert args.cmd_after == ["echo after"]
43
+
44
+
45
+ @pytest.mark.parametrize("bad_name", ["", ".", "..", "bad/name", " bad", "bad ", "bad:name"])
46
+ def test_validate_name_rejects_unsafe_names(bad_name):
47
+ with pytest.raises(ValueError):
48
+ cli.validate_name(bad_name)
49
+
50
+
51
+ def test_validate_name_accepts_safe_names():
52
+ assert cli.validate_name("g2air") == "g2air"
53
+ assert cli.validate_name("g2-air_1.0") == "g2-air_1.0"
54
+
55
+
56
+ def test_missing_required_name_exits_parser():
57
+ with pytest.raises(SystemExit) as excinfo:
58
+ cli.parse_args([])
59
+
60
+ assert excinfo.value.code == 2
61
+
62
+
63
+ def test_build_remote_command_keeps_existing_shape():
64
+ command = cli.build_remote_command("/dev/ttyHS2", "115200")
65
+
66
+ assert "stty -F /dev/ttyHS2 115200 raw -echo -crtscts cs8 -parenb -cstopb min 1 time 0" in command
67
+ assert "cat /dev/ttyHS2 & rx=$!" in command
68
+ assert "cat > /dev/ttyHS2" in command