uclog 4__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.
uclog-4/PKG-INFO ADDED
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: uclog
3
+ Version: 4
4
+ Summary: ucLog for scripts: talk to devices through the ucLog hub and decode their logs
5
+ Author: Unit Circle Inc.
6
+ License: Apache-2.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Software Development :: Embedded Systems
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Provides-Extra: mdns
14
+ Requires-Dist: zeroconf>=0.38; extra == "mdns"
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=7; extra == "test"
17
+
18
+ # uclog: Python library for scripts
19
+
20
+ Plan: [docs/plans/06-python-client.md](../docs/plans/06-python-client.md).
21
+
22
+ A small library for test scripts and automation. It does four things:
23
+
24
+ - finds a running ucLog hub, or starts one;
25
+ - connects to it;
26
+ - sends and receives on any port;
27
+ - decodes logs with CBOR logdata.
28
+
29
+ All device I/O goes through the native hub (`uclog-hub`), so the library is pure Python and
30
+ behaves the same on every OS. It needs Python 3.9 or later and has no dependencies. The optional
31
+ `uclog[mdns]` extra adds `zeroconf`, for finding hubs on other machines.
32
+
33
+ ```sh
34
+ pip install uclog # platform wheels bundle uclog-hub
35
+ pip install "uclog[mdns]" # also find remote hubs over mDNS
36
+ ```
37
+
38
+ The `py3-none-any` wheel has no `uclog-hub`. It uses `$UCLOG_HUB`, or `uclog-hub` (or `uclog`) on
39
+ `PATH`.
40
+
41
+ ## Quick start
42
+
43
+ ```python
44
+ import uclog
45
+
46
+ with uclog.connect(logdata="build/zephyr/zephyr.elf") as dev:
47
+ print(dev.device_info) # port-62 map: board, app_hash, ...
48
+ dev.send(61, "version s\r") # str or bytes, to any port
49
+ print(dev.recv(61, timeout=1.0)) # next payload on port 61, or None
50
+ rsp = dev.command({"cmd": "get-info"}) # port 0: CBOR request, waits for rsp == cmd
51
+ for rec in dev.logs(duration=5.0): # decoded log records for 5 s
52
+ print(rec) # 1.234:INFO:src/main.c:10:Hello World!
53
+ ```
54
+
55
+ The examples in [examples/](examples/) replace the legacy scripts:
56
+
57
+ | Example | |
58
+ |---|---|
59
+ | `capture_logs.py` | logs, filtered by level, source and text (replaces `uclog-cli.py`) |
60
+ | `terminal.py` | a line-mode terminal, also piped (replaces `logcmd.py`; works on Windows too) |
61
+ | `command.py` | a CBOR request on port 0 and its reply (replaces `StreamClient.txrx`) |
62
+ | `decode_capture.py` | decode a raw capture of a device's bytes offline |
63
+ | `pytest/` | a pytest fixture for hardware tests (`--uclog-device`), and tests that use it |
64
+
65
+ `tests/test_examples.py` runs every example against the simulated device. The API reference is
66
+ generated from the docstrings for the docs site (`website/build.py`).
67
+
68
+ ## Choosing a hub
69
+
70
+ `uclog.connect(host=None, device=None, ...)` goes through these steps in order:
71
+
72
+ 1. **`host` given** (or `$UCLOG_HOST`): connect to it. Examples: `"192.168.1.20"`,
73
+ `"labpc.local:9001"`, `"tcp://[::1]:9000"`. The default port is 9000.
74
+ 2. **One running hub** on this machine (registry), or also on the network with
75
+ `discover_remote=True`: connect to it. With `device=` (a URI like `serial:/dev/ttyACM0` or a
76
+ serial number), only that device's hub counts.
77
+ 3. **Several hubs** match: `MultipleHubsError`. Its `.candidates` lists them; pass `host=`. The
78
+ library never guesses.
79
+ 4. **No hub**: it runs `uclog-hub list --json`. If exactly one device is found, or `device=` names
80
+ one, it starts a hub for it and connects. If no device matches it raises `NoHubError`; if
81
+ several match, `MultipleHubsError`.
82
+
83
+ A hub the library started is **stopped when the connection closes**, at the end of the `with`
84
+ block, or when the script exits. If the script is killed, the hub sees its stdin close and exits.
85
+ Hubs that were already running are left alone. A started hub listens on `127.0.0.1` only and is
86
+ not advertised over mDNS (`hub_args=["--bind", "0.0.0.0"]` changes that); local clients still
87
+ find it in the registry. Scripts starting a hub for the same device at the same time take turns,
88
+ so only one hub is started.
89
+
90
+ `connect()` returns once **the device** has sent something (not just the hub's backlog replay),
91
+ waiting up to `device_timeout` (10 s; a hub opens a BLE device in about 5 s). Otherwise it raises
92
+ `DeviceNotRespondingError`.
93
+
94
+ ```python
95
+ uclog.list_hubs() # [HubInfo(name, host, port, device, serial_number, busy, pid, ...)]
96
+ uclog.list_hubs(discover_remote=True)
97
+ uclog.list_devices() # [DeviceInfo(kind, uri, name, serial, description)]
98
+ hub = uclog.start_hub("serial:/dev/ttyACM0") # keep a hub across several connections
99
+ uclog.stop_hub(hub.pid) # or uclog.stop_hub("localhost:9001"); only a registered hub
100
+ ```
101
+
102
+ A hub serves **one client at a time**. While the GUI, the TUI or VS Code is connected,
103
+ `connect()` raises `HubBusyError`. A remote hub may hold a new connection open, without data, while
104
+ its operator approves it. `connect()` waits up to `approval_timeout` (60 s), then raises
105
+ `ApprovalTimeoutError`.
106
+
107
+ ## Connection
108
+
109
+ | Call | |
110
+ |---|---|
111
+ | `send(port, data)` | bytes, or str as UTF-8; at most 1443 bytes |
112
+ | `send_frame(payload)` | a whole payload, frame header included |
113
+ | `recv(port, timeout=None)` | next payload on `port` (bytes), or `None` after `timeout` |
114
+ | `command(obj, timeout=2.0)` | CBOR map on port 0; returns the reply whose `rsp` equals `obj["cmd"]`, or `None`. Other port-0 frames stay queued for `recv(0)` |
115
+ | `logs(timeout=None, duration=None)` | iterator of `LogRecord`s, ending after `timeout` seconds without one or `duration` seconds in all |
116
+ | `frames(timeout=None, duration=None)` | every payload, raw |
117
+ | `console(timeout=None)` | semihost stdout/stderr: `(fid, bytes)` |
118
+ | `device_info` | the port-62 map; waits up to 2 s for it, or until `connect()`'s `device_timeout` ends if that is later |
119
+ | `alive` | bytes arrived within the last 2 s |
120
+ | `dropped` | entries dropped from full queues, by port, `"logs"`, `"frames"` or `"console"` |
121
+ | `notes` | logdata choices and problems (also logged to the `uclog` logger) |
122
+ | `load_logdata(path)` | use another logdata file from now on |
123
+ | `close()` | sends what is still queued (up to 2 s), then closes; also stops a hub the library started |
124
+
125
+ `LogRecord(ts, level, target, file, line, text, error)`:
126
+ - `ts` is seconds since the connection opened.
127
+ - `error` is set for error records: `no_logdata`, `unknown_format`, `missing_arg`, `extra_bytes`,
128
+ `framing_invalid`, and so on, with the same texts as the native tools.
129
+ - `str(rec)` gives the legacy `ts:level:file:line:text` line.
130
+ - Logs from the backlog are decoded with the logdata of the device info that preceded them, so
131
+ logs from before a reflash use the old image's logdata. An image with no logdata gives
132
+ `no_logdata` records.
133
+
134
+ **Queues** are bounded by `max_queue` (10 000). When one is full, the oldest entry is dropped and
135
+ counted in `dropped`.
136
+
137
+ **Backlog:** on connect, the hub first replays what the device sent while no client was
138
+ connected. `skip_backlog=True` drops that replay.
139
+
140
+ **asyncio:** `uclog.aio.connect(...)` takes the same arguments. Its connection has the same
141
+ calls, awaited, except that `device_info` becomes the method `await dev.device_info(timeout)`.
142
+ `logs()` and `frames()` are async iterators.
143
+
144
+ ```python
145
+ async with await uclog.aio.connect(device="serial:/dev/ttyACM0") as dev:
146
+ await dev.send(61, "help\r")
147
+ print(await dev.recv(61, timeout=1))
148
+ ```
149
+
150
+ ## Logdata
151
+
152
+ `connect(logdata=...)` takes one file or a list: `.logdata`, or an ELF file with a
153
+ `.logdata_cbor` section. Each device info (port 62) selects the logdata for its `app_hash`, in this
154
+ order:
155
+
156
+ 1. **The given files.** A file named `<app_hash>.logdata` is used only for that hash.
157
+ 2. **The cache directory.** `cache_dir`, which defaults to the native tools' cache
158
+ (`$UCLOG_CACHE_DIR`, else `$XDG_CACHE_HOME/uclog`, else `~/.cache/uclog`), holding
159
+ `<app_hash>.logdata` files. The firmware builds copy theirs there.
160
+ 3. **fw.unitcircle.ca.** Used when `logdata_domain` (or `$UCLOG_LOGDATA_DOMAIN`) is set, with
161
+ `logdata_token` / `$UCLOG_LOGDATA_TOKEN`. The download is saved to the cache. A refused token
162
+ (401) is retried once without it, for accounts that allow reading without a token; the
163
+ resolver's `token_refused` is then set and `notes` says so.
164
+ 4. **The first given file**, with a warning in `notes`.
165
+
166
+ `uclog.LogData.load(path).decode(addr, args)` decodes without a connection.
167
+
168
+ ## Port 63 (semihost)
169
+
170
+ A connected script answers the device's syscalls. The hub answers the time calls itself.
171
+
172
+ - **stdout and stderr** writes go to `console()`.
173
+ - **stdin reads** call `stdin=` (a function taking the maximum byte count and returning bytes or
174
+ str), or get EOF.
175
+ - **File operations** (OPEN, MKDIR) get `EACCES`, unless `files_dir=DIR` is given. Then they
176
+ work inside `DIR` only: absolute paths, `..` escapes, symlinks that leave it and names that are
177
+ not portable (protocol.md §5.5.5) fail with `EACCES`. Errors use newlib errno values.
178
+
179
+ The connection's own thread answers syscalls, so a script that never reads from it still does not
180
+ stall the device.
181
+
182
+ ## Migrating from the legacy package
183
+
184
+ The legacy repository's `uclog` package uses the same import name. Uninstall it, or use a separate
185
+ virtual environment.
186
+
187
+ | Legacy | Now |
188
+ |---|---|
189
+ | `uclog.StreamClient(target, host, raw, stream=0, enc='utf8')` | `uclog.connect(host=..., device=...)` |
190
+ | `sc.tx(obj)` with `enc='cbor'` | `dev.send(port, uclog.cbor.dumps(obj))`, or `dev.command(obj)` for request/reply |
191
+ | `sc.tx(text)` with `enc='utf8'` | `dev.send(port, text)` |
192
+ | `sc.rx(timeout)` | `dev.recv(port, timeout)`; `uclog.cbor.loads(...)` for CBOR |
193
+ | `sc.txrx(obj)` | `dev.command(obj)` |
194
+ | `enc='cbor,crc,cobs'` (legacy port-3 FW update) | `uclog fw-update` |
195
+ | `--target /dev/tty...` (opens the port directly) | `device="serial:/dev/tty..."` (through a hub) |
196
+ | `uclog-cli.py` | `examples/capture_logs.py`, or `uclog tui --plain` |
197
+ | `logcmd.py` | `examples/terminal.py`, or `uclog term` |
198
+
199
+ FW update from Python is out of scope for v1 (decision 30). Use `uclog fw-update`.
200
+
201
+ ## Development
202
+
203
+ ```sh
204
+ cd python
205
+ uv run --no-project --with pytest pytest # or: pip install -e ".[test]" && pytest
206
+ ```
207
+
208
+ The integration tests use `$UCLOG_HUB` or `native/target/{release,debug}/uclog-hub`, and the
209
+ simulated `loopback:` device. Without either executable they are skipped.
210
+
211
+ To build a **platform wheel** that bundles the hub:
212
+
213
+ ```sh
214
+ UCLOG_HUB_BINARY=../native/target/release/uclog-hub UCLOG_WHEEL_PLAT=macosx_11_0_arm64 python -m build --wheel
215
+ ```
uclog-4/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # uclog: Python library for scripts
2
+
3
+ Plan: [docs/plans/06-python-client.md](../docs/plans/06-python-client.md).
4
+
5
+ A small library for test scripts and automation. It does four things:
6
+
7
+ - finds a running ucLog hub, or starts one;
8
+ - connects to it;
9
+ - sends and receives on any port;
10
+ - decodes logs with CBOR logdata.
11
+
12
+ All device I/O goes through the native hub (`uclog-hub`), so the library is pure Python and
13
+ behaves the same on every OS. It needs Python 3.9 or later and has no dependencies. The optional
14
+ `uclog[mdns]` extra adds `zeroconf`, for finding hubs on other machines.
15
+
16
+ ```sh
17
+ pip install uclog # platform wheels bundle uclog-hub
18
+ pip install "uclog[mdns]" # also find remote hubs over mDNS
19
+ ```
20
+
21
+ The `py3-none-any` wheel has no `uclog-hub`. It uses `$UCLOG_HUB`, or `uclog-hub` (or `uclog`) on
22
+ `PATH`.
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ import uclog
28
+
29
+ with uclog.connect(logdata="build/zephyr/zephyr.elf") as dev:
30
+ print(dev.device_info) # port-62 map: board, app_hash, ...
31
+ dev.send(61, "version s\r") # str or bytes, to any port
32
+ print(dev.recv(61, timeout=1.0)) # next payload on port 61, or None
33
+ rsp = dev.command({"cmd": "get-info"}) # port 0: CBOR request, waits for rsp == cmd
34
+ for rec in dev.logs(duration=5.0): # decoded log records for 5 s
35
+ print(rec) # 1.234:INFO:src/main.c:10:Hello World!
36
+ ```
37
+
38
+ The examples in [examples/](examples/) replace the legacy scripts:
39
+
40
+ | Example | |
41
+ |---|---|
42
+ | `capture_logs.py` | logs, filtered by level, source and text (replaces `uclog-cli.py`) |
43
+ | `terminal.py` | a line-mode terminal, also piped (replaces `logcmd.py`; works on Windows too) |
44
+ | `command.py` | a CBOR request on port 0 and its reply (replaces `StreamClient.txrx`) |
45
+ | `decode_capture.py` | decode a raw capture of a device's bytes offline |
46
+ | `pytest/` | a pytest fixture for hardware tests (`--uclog-device`), and tests that use it |
47
+
48
+ `tests/test_examples.py` runs every example against the simulated device. The API reference is
49
+ generated from the docstrings for the docs site (`website/build.py`).
50
+
51
+ ## Choosing a hub
52
+
53
+ `uclog.connect(host=None, device=None, ...)` goes through these steps in order:
54
+
55
+ 1. **`host` given** (or `$UCLOG_HOST`): connect to it. Examples: `"192.168.1.20"`,
56
+ `"labpc.local:9001"`, `"tcp://[::1]:9000"`. The default port is 9000.
57
+ 2. **One running hub** on this machine (registry), or also on the network with
58
+ `discover_remote=True`: connect to it. With `device=` (a URI like `serial:/dev/ttyACM0` or a
59
+ serial number), only that device's hub counts.
60
+ 3. **Several hubs** match: `MultipleHubsError`. Its `.candidates` lists them; pass `host=`. The
61
+ library never guesses.
62
+ 4. **No hub**: it runs `uclog-hub list --json`. If exactly one device is found, or `device=` names
63
+ one, it starts a hub for it and connects. If no device matches it raises `NoHubError`; if
64
+ several match, `MultipleHubsError`.
65
+
66
+ A hub the library started is **stopped when the connection closes**, at the end of the `with`
67
+ block, or when the script exits. If the script is killed, the hub sees its stdin close and exits.
68
+ Hubs that were already running are left alone. A started hub listens on `127.0.0.1` only and is
69
+ not advertised over mDNS (`hub_args=["--bind", "0.0.0.0"]` changes that); local clients still
70
+ find it in the registry. Scripts starting a hub for the same device at the same time take turns,
71
+ so only one hub is started.
72
+
73
+ `connect()` returns once **the device** has sent something (not just the hub's backlog replay),
74
+ waiting up to `device_timeout` (10 s; a hub opens a BLE device in about 5 s). Otherwise it raises
75
+ `DeviceNotRespondingError`.
76
+
77
+ ```python
78
+ uclog.list_hubs() # [HubInfo(name, host, port, device, serial_number, busy, pid, ...)]
79
+ uclog.list_hubs(discover_remote=True)
80
+ uclog.list_devices() # [DeviceInfo(kind, uri, name, serial, description)]
81
+ hub = uclog.start_hub("serial:/dev/ttyACM0") # keep a hub across several connections
82
+ uclog.stop_hub(hub.pid) # or uclog.stop_hub("localhost:9001"); only a registered hub
83
+ ```
84
+
85
+ A hub serves **one client at a time**. While the GUI, the TUI or VS Code is connected,
86
+ `connect()` raises `HubBusyError`. A remote hub may hold a new connection open, without data, while
87
+ its operator approves it. `connect()` waits up to `approval_timeout` (60 s), then raises
88
+ `ApprovalTimeoutError`.
89
+
90
+ ## Connection
91
+
92
+ | Call | |
93
+ |---|---|
94
+ | `send(port, data)` | bytes, or str as UTF-8; at most 1443 bytes |
95
+ | `send_frame(payload)` | a whole payload, frame header included |
96
+ | `recv(port, timeout=None)` | next payload on `port` (bytes), or `None` after `timeout` |
97
+ | `command(obj, timeout=2.0)` | CBOR map on port 0; returns the reply whose `rsp` equals `obj["cmd"]`, or `None`. Other port-0 frames stay queued for `recv(0)` |
98
+ | `logs(timeout=None, duration=None)` | iterator of `LogRecord`s, ending after `timeout` seconds without one or `duration` seconds in all |
99
+ | `frames(timeout=None, duration=None)` | every payload, raw |
100
+ | `console(timeout=None)` | semihost stdout/stderr: `(fid, bytes)` |
101
+ | `device_info` | the port-62 map; waits up to 2 s for it, or until `connect()`'s `device_timeout` ends if that is later |
102
+ | `alive` | bytes arrived within the last 2 s |
103
+ | `dropped` | entries dropped from full queues, by port, `"logs"`, `"frames"` or `"console"` |
104
+ | `notes` | logdata choices and problems (also logged to the `uclog` logger) |
105
+ | `load_logdata(path)` | use another logdata file from now on |
106
+ | `close()` | sends what is still queued (up to 2 s), then closes; also stops a hub the library started |
107
+
108
+ `LogRecord(ts, level, target, file, line, text, error)`:
109
+ - `ts` is seconds since the connection opened.
110
+ - `error` is set for error records: `no_logdata`, `unknown_format`, `missing_arg`, `extra_bytes`,
111
+ `framing_invalid`, and so on, with the same texts as the native tools.
112
+ - `str(rec)` gives the legacy `ts:level:file:line:text` line.
113
+ - Logs from the backlog are decoded with the logdata of the device info that preceded them, so
114
+ logs from before a reflash use the old image's logdata. An image with no logdata gives
115
+ `no_logdata` records.
116
+
117
+ **Queues** are bounded by `max_queue` (10 000). When one is full, the oldest entry is dropped and
118
+ counted in `dropped`.
119
+
120
+ **Backlog:** on connect, the hub first replays what the device sent while no client was
121
+ connected. `skip_backlog=True` drops that replay.
122
+
123
+ **asyncio:** `uclog.aio.connect(...)` takes the same arguments. Its connection has the same
124
+ calls, awaited, except that `device_info` becomes the method `await dev.device_info(timeout)`.
125
+ `logs()` and `frames()` are async iterators.
126
+
127
+ ```python
128
+ async with await uclog.aio.connect(device="serial:/dev/ttyACM0") as dev:
129
+ await dev.send(61, "help\r")
130
+ print(await dev.recv(61, timeout=1))
131
+ ```
132
+
133
+ ## Logdata
134
+
135
+ `connect(logdata=...)` takes one file or a list: `.logdata`, or an ELF file with a
136
+ `.logdata_cbor` section. Each device info (port 62) selects the logdata for its `app_hash`, in this
137
+ order:
138
+
139
+ 1. **The given files.** A file named `<app_hash>.logdata` is used only for that hash.
140
+ 2. **The cache directory.** `cache_dir`, which defaults to the native tools' cache
141
+ (`$UCLOG_CACHE_DIR`, else `$XDG_CACHE_HOME/uclog`, else `~/.cache/uclog`), holding
142
+ `<app_hash>.logdata` files. The firmware builds copy theirs there.
143
+ 3. **fw.unitcircle.ca.** Used when `logdata_domain` (or `$UCLOG_LOGDATA_DOMAIN`) is set, with
144
+ `logdata_token` / `$UCLOG_LOGDATA_TOKEN`. The download is saved to the cache. A refused token
145
+ (401) is retried once without it, for accounts that allow reading without a token; the
146
+ resolver's `token_refused` is then set and `notes` says so.
147
+ 4. **The first given file**, with a warning in `notes`.
148
+
149
+ `uclog.LogData.load(path).decode(addr, args)` decodes without a connection.
150
+
151
+ ## Port 63 (semihost)
152
+
153
+ A connected script answers the device's syscalls. The hub answers the time calls itself.
154
+
155
+ - **stdout and stderr** writes go to `console()`.
156
+ - **stdin reads** call `stdin=` (a function taking the maximum byte count and returning bytes or
157
+ str), or get EOF.
158
+ - **File operations** (OPEN, MKDIR) get `EACCES`, unless `files_dir=DIR` is given. Then they
159
+ work inside `DIR` only: absolute paths, `..` escapes, symlinks that leave it and names that are
160
+ not portable (protocol.md §5.5.5) fail with `EACCES`. Errors use newlib errno values.
161
+
162
+ The connection's own thread answers syscalls, so a script that never reads from it still does not
163
+ stall the device.
164
+
165
+ ## Migrating from the legacy package
166
+
167
+ The legacy repository's `uclog` package uses the same import name. Uninstall it, or use a separate
168
+ virtual environment.
169
+
170
+ | Legacy | Now |
171
+ |---|---|
172
+ | `uclog.StreamClient(target, host, raw, stream=0, enc='utf8')` | `uclog.connect(host=..., device=...)` |
173
+ | `sc.tx(obj)` with `enc='cbor'` | `dev.send(port, uclog.cbor.dumps(obj))`, or `dev.command(obj)` for request/reply |
174
+ | `sc.tx(text)` with `enc='utf8'` | `dev.send(port, text)` |
175
+ | `sc.rx(timeout)` | `dev.recv(port, timeout)`; `uclog.cbor.loads(...)` for CBOR |
176
+ | `sc.txrx(obj)` | `dev.command(obj)` |
177
+ | `enc='cbor,crc,cobs'` (legacy port-3 FW update) | `uclog fw-update` |
178
+ | `--target /dev/tty...` (opens the port directly) | `device="serial:/dev/tty..."` (through a hub) |
179
+ | `uclog-cli.py` | `examples/capture_logs.py`, or `uclog tui --plain` |
180
+ | `logcmd.py` | `examples/terminal.py`, or `uclog term` |
181
+
182
+ FW update from Python is out of scope for v1 (decision 30). Use `uclog fw-update`.
183
+
184
+ ## Development
185
+
186
+ ```sh
187
+ cd python
188
+ uv run --no-project --with pytest pytest # or: pip install -e ".[test]" && pytest
189
+ ```
190
+
191
+ The integration tests use `$UCLOG_HUB` or `native/target/{release,debug}/uclog-hub`, and the
192
+ simulated `loopback:` device. Without either executable they are skipped.
193
+
194
+ To build a **platform wheel** that bundles the hub:
195
+
196
+ ```sh
197
+ UCLOG_HUB_BINARY=../native/target/release/uclog-hub UCLOG_WHEEL_PLAT=macosx_11_0_arm64 python -m build --wheel
198
+ ```
uclog-4/pyproject.toml ADDED
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "uclog"
7
+ version = "4"
8
+ description = "ucLog for scripts: talk to devices through the ucLog hub and decode their logs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Unit Circle Inc." }]
13
+ dependencies = []
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Embedded Systems",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ mdns = ["zeroconf>=0.38"]
23
+ test = ["pytest>=7"]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ uclog = ["py.typed"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
uclog-4/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
uclog-4/setup.py ADDED
@@ -0,0 +1,55 @@
1
+ # © 2026 Unit Circle Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Platform wheels (plan 06 §2): with UCLOG_HUB_BINARY (the uclog-hub executable) and
5
+ UCLOG_WHEEL_PLAT (a wheel platform tag such as manylinux_2_28_x86_64, macosx_11_0_arm64 or
6
+ win_amd64), the wheel bundles uclog-hub as uclog/bin/uclog-hub and is tagged py3-none-<plat>.
7
+ Without them it is the pure-Python py3-none-any wheel.
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+
13
+ from setuptools import Distribution, setup
14
+ from setuptools.command.build_py import build_py
15
+
16
+ try:
17
+ from setuptools.command.bdist_wheel import bdist_wheel
18
+ except ImportError: # setuptools < 70.1
19
+ from wheel.bdist_wheel import bdist_wheel
20
+
21
+ HUB = os.environ.get('UCLOG_HUB_BINARY')
22
+ PLAT = os.environ.get('UCLOG_WHEEL_PLAT')
23
+ if HUB and not PLAT:
24
+ raise SystemExit('UCLOG_HUB_BINARY needs UCLOG_WHEEL_PLAT (the wheel platform tag)')
25
+
26
+
27
+ class BuildPy(build_py):
28
+ def run(self):
29
+ super().run()
30
+ if HUB:
31
+ d = os.path.join(self.build_lib, 'uclog', 'bin')
32
+ os.makedirs(d, exist_ok=True)
33
+ name = 'uclog-hub.exe' if HUB.lower().endswith('.exe') else 'uclog-hub'
34
+ shutil.copy2(HUB, os.path.join(d, name))
35
+ os.chmod(os.path.join(d, name), 0o755)
36
+
37
+
38
+ class BdistWheel(bdist_wheel):
39
+ def finalize_options(self):
40
+ super().finalize_options()
41
+ if HUB:
42
+ self.root_is_pure = False
43
+
44
+ def get_tag(self):
45
+ if HUB:
46
+ return 'py3', 'none', PLAT
47
+ return super().get_tag()
48
+
49
+
50
+ class BinaryDistribution(Distribution):
51
+ def has_ext_modules(self):
52
+ return bool(HUB) # platlib, at the wheel root
53
+
54
+
55
+ setup(cmdclass={'build_py': BuildPy, 'bdist_wheel': BdistWheel}, distclass=BinaryDistribution)
@@ -0,0 +1,35 @@
1
+ # © 2026 Unit Circle Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """ucLog for scripts: connect to a device through the ucLog hub, send and receive on any port,
5
+ and decode logs.
6
+
7
+ import uclog
8
+
9
+ with uclog.connect(logdata="build/zephyr/zephyr.elf") as dev:
10
+ print(dev.device_info)
11
+ dev.send(61, b"version s\\r")
12
+ print(dev.recv(61, timeout=1.0))
13
+ for rec in dev.logs(timeout=5.0):
14
+ print(rec)
15
+
16
+ All device I/O goes through the native hub (`uclog-hub`); a hub is started when none is running
17
+ and stopped when the script exits.
18
+ """
19
+
20
+ from . import aio, cbor, proto
21
+ from .connection import Connection, connect
22
+ from .errors import (ApprovalTimeoutError, ConnectionClosedError, DeviceNotRespondingError,
23
+ HubBusyError, HubStartError, MultipleHubsError, NoHubError, UclogError)
24
+ from .hub import DeviceInfo, HubInfo, list_devices, list_hubs, start_hub, stop_hub
25
+ from .logdata import DecodeError, LogData, LogDataError, LogRecord
26
+ from .resolver import Resolver
27
+
28
+ __version__ = '4'
29
+
30
+ __all__ = [
31
+ 'connect', 'Connection', 'list_hubs', 'list_devices', 'start_hub', 'stop_hub', 'HubInfo',
32
+ 'DeviceInfo', 'LogData', 'LogRecord', 'Resolver', 'DecodeError', 'LogDataError', 'UclogError',
33
+ 'NoHubError', 'MultipleHubsError', 'HubBusyError', 'ApprovalTimeoutError',
34
+ 'DeviceNotRespondingError', 'HubStartError', 'ConnectionClosedError', 'aio', 'cbor', 'proto',
35
+ ]
@@ -0,0 +1,102 @@
1
+ # © 2026 Unit Circle Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """asyncio API: the same calls as the sync one, awaited.
5
+
6
+ async with await uclog.aio.connect(logdata="build/zephyr/zephyr.elf") as dev:
7
+ await dev.send(61, b"version s\\r")
8
+ print(await dev.recv(61, timeout=1.0))
9
+ async for rec in dev.logs(timeout=5.0):
10
+ print(rec)
11
+
12
+ The connection's I/O runs on its own threads (see `uclog.Connection`); waits run in the
13
+ default executor so the event loop is never blocked.
14
+ """
15
+
16
+ import asyncio
17
+ import functools
18
+ from typing import AsyncIterator, Optional
19
+
20
+ from . import connection
21
+ from .logdata import LogRecord
22
+
23
+ __all__ = ['connect', 'Connection']
24
+
25
+
26
+ async def _run(fn, *args, **kw):
27
+ loop = asyncio.get_running_loop()
28
+ return await loop.run_in_executor(None, functools.partial(fn, *args, **kw))
29
+
30
+
31
+ class Connection:
32
+ """Async wrapper of `uclog.Connection`. `sync` is the wrapped connection."""
33
+
34
+ def __init__(self, sync: connection.Connection):
35
+ self.sync = sync
36
+
37
+ async def __aenter__(self):
38
+ return self
39
+
40
+ async def __aexit__(self, *exc):
41
+ await self.close()
42
+ return False
43
+
44
+ @property
45
+ def alive(self) -> bool:
46
+ return self.sync.alive
47
+
48
+ @property
49
+ def closed(self) -> bool:
50
+ return self.sync.closed
51
+
52
+ @property
53
+ def dropped(self):
54
+ return self.sync.dropped
55
+
56
+ @property
57
+ def notes(self):
58
+ return self.sync.notes
59
+
60
+ async def device_info(self, timeout: Optional[float] = None) -> Optional[dict]:
61
+ """The port-62 map. Without `timeout`, waits as long as the sync `device_info` property."""
62
+ return await _run(self.sync.wait_device_info,
63
+ self.sync._info_timeout() if timeout is None else timeout)
64
+
65
+ async def send(self, port: int, data) -> None:
66
+ self.sync.send(port, data)
67
+
68
+ async def send_frame(self, payload: bytes) -> None:
69
+ self.sync.send_frame(payload)
70
+
71
+ async def recv(self, port: int, timeout: Optional[float] = None) -> Optional[bytes]:
72
+ return await _run(self.sync.recv, port, timeout)
73
+
74
+ async def console(self, timeout: Optional[float] = None):
75
+ return await _run(self.sync.console, timeout)
76
+
77
+ async def command(self, obj: dict, timeout: float = 2.0):
78
+ return await _run(self.sync.command, obj, timeout)
79
+
80
+ async def logs(self, timeout: Optional[float] = None, duration: Optional[float] = None) -> AsyncIterator[LogRecord]:
81
+ it = self.sync.logs(timeout, duration)
82
+ while True:
83
+ r = await _run(next, it, None)
84
+ if r is None:
85
+ return
86
+ yield r
87
+
88
+ async def frames(self, timeout: Optional[float] = None, duration: Optional[float] = None) -> AsyncIterator[bytes]:
89
+ it = self.sync.frames(timeout, duration)
90
+ while True:
91
+ r = await _run(next, it, None)
92
+ if r is None:
93
+ return
94
+ yield r
95
+
96
+ async def close(self) -> None:
97
+ await _run(self.sync.close)
98
+
99
+
100
+ async def connect(*args, **kw) -> Connection:
101
+ """Same arguments as `uclog.connect()`."""
102
+ return Connection(await _run(connection.connect, *args, **kw))