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 +215 -0
- uclog-4/README.md +198 -0
- uclog-4/pyproject.toml +32 -0
- uclog-4/setup.cfg +4 -0
- uclog-4/setup.py +55 -0
- uclog-4/src/uclog/__init__.py +35 -0
- uclog-4/src/uclog/aio.py +102 -0
- uclog-4/src/uclog/cbor.py +160 -0
- uclog-4/src/uclog/connection.py +677 -0
- uclog-4/src/uclog/errors.py +53 -0
- uclog-4/src/uclog/hub.py +490 -0
- uclog-4/src/uclog/logdata.py +381 -0
- uclog-4/src/uclog/proto.py +180 -0
- uclog-4/src/uclog/py.typed +0 -0
- uclog-4/src/uclog/resolver.py +161 -0
- uclog-4/src/uclog/semihost.py +296 -0
- uclog-4/src/uclog.egg-info/PKG-INFO +215 -0
- uclog-4/src/uclog.egg-info/SOURCES.txt +26 -0
- uclog-4/src/uclog.egg-info/dependency_links.txt +1 -0
- uclog-4/src/uclog.egg-info/requires.txt +6 -0
- uclog-4/src/uclog.egg-info/top_level.txt +1 -0
- uclog-4/tests/test_cbor.py +57 -0
- uclog-4/tests/test_examples.py +77 -0
- uclog-4/tests/test_fake_hub.py +344 -0
- uclog-4/tests/test_hub.py +219 -0
- uclog-4/tests/test_logdata.py +152 -0
- uclog-4/tests/test_semihost.py +183 -0
- uclog-4/tests/test_vectors.py +130 -0
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
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
|
+
]
|
uclog-4/src/uclog/aio.py
ADDED
|
@@ -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))
|