uclog 4__py3-none-any.whl
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/__init__.py +35 -0
- uclog/aio.py +102 -0
- uclog/cbor.py +160 -0
- uclog/connection.py +677 -0
- uclog/errors.py +53 -0
- uclog/hub.py +490 -0
- uclog/logdata.py +381 -0
- uclog/proto.py +180 -0
- uclog/py.typed +0 -0
- uclog/resolver.py +161 -0
- uclog/semihost.py +296 -0
- uclog-4.dist-info/METADATA +215 -0
- uclog-4.dist-info/RECORD +15 -0
- uclog-4.dist-info/WHEEL +5 -0
- uclog-4.dist-info/top_level.txt +1 -0
uclog/connection.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
# © 2026 Unit Circle Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""A connection to a hub (plan 06 §3–§5)."""
|
|
5
|
+
|
|
6
|
+
import collections
|
|
7
|
+
import ipaddress
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Iterator, Optional
|
|
14
|
+
|
|
15
|
+
from . import cbor, proto
|
|
16
|
+
from .errors import (ApprovalTimeoutError, ConnectionClosedError, DeviceNotRespondingError,
|
|
17
|
+
HubBusyError, MultipleHubsError, NoHubError)
|
|
18
|
+
from .hub import DEFAULT_PORT, list_devices, list_hubs, spawn_lock, start_hub
|
|
19
|
+
from .logdata import DecodeError, LogData, LogRecord
|
|
20
|
+
from .resolver import Resolver
|
|
21
|
+
from .semihost import Handler
|
|
22
|
+
|
|
23
|
+
__all__ = ['Connection', 'connect']
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger('uclog')
|
|
26
|
+
|
|
27
|
+
PULSE_INTERVAL = 0.5
|
|
28
|
+
LIVENESS_TIMEOUT = 2.0
|
|
29
|
+
WAKE_GAP = 0.05
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hex(b, n=32):
|
|
33
|
+
s = bytes(b[:n]).hex()
|
|
34
|
+
return s + ('…' if len(b) > n else '')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_host(host: str):
|
|
38
|
+
""""host", "host:port", "[v6]:port" or "tcp://host:port" → (host, port)."""
|
|
39
|
+
h = host.strip()
|
|
40
|
+
for scheme in ('tcp://', 'uclog://'):
|
|
41
|
+
if h.startswith(scheme):
|
|
42
|
+
h = h[len(scheme):]
|
|
43
|
+
h = h.rstrip('/')
|
|
44
|
+
if h.startswith('['):
|
|
45
|
+
addr, _, rest = h[1:].partition(']')
|
|
46
|
+
return addr, int(rest[1:]) if rest.startswith(':') else DEFAULT_PORT
|
|
47
|
+
if h.count(':') == 1:
|
|
48
|
+
addr, port = h.split(':')
|
|
49
|
+
return addr, int(port)
|
|
50
|
+
return h, DEFAULT_PORT
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Connection:
|
|
54
|
+
"""One client connection to a hub. Create it with `uclog.connect()`.
|
|
55
|
+
|
|
56
|
+
A reader thread receives frames, answers port-63 syscalls and decodes logs; a writer thread
|
|
57
|
+
sends frames and a pulse every 0.5 s. Received data waits in bounded queues: per port
|
|
58
|
+
(`recv`), all frames (`frames`), log records (`logs`) and semihost console output (`console`).
|
|
59
|
+
When a queue is full the oldest entry is dropped and counted in `dropped`.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, sock: socket.socket, *, resolver: Resolver = None, files_dir=None,
|
|
63
|
+
stdin=None, max_queue: int = 10000, skip_backlog: bool = False, hub=None,
|
|
64
|
+
address='', manual=None):
|
|
65
|
+
self.address = address
|
|
66
|
+
self._sock = sock
|
|
67
|
+
self._resolver = resolver or Resolver()
|
|
68
|
+
self._semihost = Handler(files_dir)
|
|
69
|
+
self._stdin = stdin
|
|
70
|
+
self._max = max_queue
|
|
71
|
+
self._skip = skip_backlog
|
|
72
|
+
self._hub = hub
|
|
73
|
+
self._start = time.monotonic()
|
|
74
|
+
self._cv = threading.Condition(threading.RLock())
|
|
75
|
+
self._ports = collections.defaultdict(collections.deque)
|
|
76
|
+
self._frames = collections.deque()
|
|
77
|
+
self._logs = collections.deque()
|
|
78
|
+
self._console = collections.deque()
|
|
79
|
+
self._seq = 0
|
|
80
|
+
#: Entries dropped from full queues, by port number or 'frames', 'logs', 'console'.
|
|
81
|
+
self.dropped = collections.Counter()
|
|
82
|
+
#: Resolver notes and other messages (also logged to the `uclog` logger).
|
|
83
|
+
self.notes = []
|
|
84
|
+
self._info = None
|
|
85
|
+
self._app_hash = None
|
|
86
|
+
self._decoders = {}
|
|
87
|
+
for ld in (self._resolver.manual() if manual is None else manual):
|
|
88
|
+
self._decoders[ld.target] = ld
|
|
89
|
+
# Decoding contexts whose logdata is still being resolved, oldest first (protocol.md §5.2:
|
|
90
|
+
# every port-62 frame starts one). Each holds the logs that arrived after its device info,
|
|
91
|
+
# decoded with its own logdata once it resolves.
|
|
92
|
+
self._contexts = collections.deque()
|
|
93
|
+
self._live = not skip_backlog
|
|
94
|
+
self._first_rx = None
|
|
95
|
+
self._last_rx = None
|
|
96
|
+
self._replayed = False # the hub's end-of-backlog pulse has been seen (protocol.md §8)
|
|
97
|
+
self._device_rx = None # when the first byte after that pulse arrived: the device is open
|
|
98
|
+
self._device_deadline = None # set by connect(): device_info waits until then
|
|
99
|
+
self._closed = False
|
|
100
|
+
self._out = collections.deque()
|
|
101
|
+
self._out_cv = threading.Condition()
|
|
102
|
+
self._sending = False # the writer holds a batch it has not finished sending
|
|
103
|
+
self._write_done = False # the writer thread has ended
|
|
104
|
+
self._scanner = proto.FrameScanner()
|
|
105
|
+
self._zeros = 0 # length of the current run of 00 bytes
|
|
106
|
+
self._stream_start = True
|
|
107
|
+
self._reader = threading.Thread(target=self._read_loop, name='uclog-reader', daemon=True)
|
|
108
|
+
self._writer = threading.Thread(target=self._write_loop, name='uclog-writer', daemon=True)
|
|
109
|
+
self._reader.start()
|
|
110
|
+
self._writer.start()
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------ public API
|
|
113
|
+
|
|
114
|
+
def __enter__(self):
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc):
|
|
118
|
+
self.close()
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def __repr__(self):
|
|
122
|
+
return f'<uclog.Connection {self.address}{" closed" if self._closed else ""}>'
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def closed(self) -> bool:
|
|
126
|
+
"""The connection is closed (by `close()`, or by the hub)."""
|
|
127
|
+
return self._closed
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def alive(self) -> bool:
|
|
131
|
+
"""Bytes (data or pulses) arrived within the last 2 s."""
|
|
132
|
+
return self._last_rx is not None and time.monotonic() - self._last_rx <= LIVENESS_TIMEOUT
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def device_info(self) -> Optional[dict]:
|
|
136
|
+
"""The port-62 device info (board, app_hash, ...). Waits up to 2 s for it, or longer while
|
|
137
|
+
the hub may still be opening the device (until connect()'s `device_timeout` ends)."""
|
|
138
|
+
return self.wait_device_info(self._info_timeout())
|
|
139
|
+
|
|
140
|
+
def _info_timeout(self):
|
|
141
|
+
t = 2.0
|
|
142
|
+
if self._device_deadline is not None:
|
|
143
|
+
t = max(t, self._device_deadline - time.monotonic())
|
|
144
|
+
return t
|
|
145
|
+
|
|
146
|
+
def wait_device_info(self, timeout: Optional[float] = 2.0) -> Optional[dict]:
|
|
147
|
+
"""The device info, waiting up to `timeout` seconds for it (None: as long as it takes); None
|
|
148
|
+
if none came."""
|
|
149
|
+
with self._cv:
|
|
150
|
+
self._cv.wait_for(lambda: self._info is not None or self._closed, timeout)
|
|
151
|
+
return self._info
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def logdata(self) -> dict:
|
|
155
|
+
"""The logdata in use, by target digit."""
|
|
156
|
+
with self._cv:
|
|
157
|
+
return dict(self._decoders)
|
|
158
|
+
|
|
159
|
+
def load_logdata(self, path) -> LogData:
|
|
160
|
+
"""Use a logdata file (or ELF) from now on, for its target."""
|
|
161
|
+
ld = LogData.load(path)
|
|
162
|
+
with self._cv:
|
|
163
|
+
self._decoders[ld.target] = ld
|
|
164
|
+
return ld
|
|
165
|
+
|
|
166
|
+
def send(self, port: int, data) -> None:
|
|
167
|
+
"""Send `data` (bytes, or str as UTF-8) on `port`."""
|
|
168
|
+
if isinstance(data, str):
|
|
169
|
+
data = data.encode('utf-8')
|
|
170
|
+
if len(data) > proto.MAX_PORT_DATA:
|
|
171
|
+
raise ValueError(f'{len(data)} bytes exceed the {proto.MAX_PORT_DATA}-byte port limit')
|
|
172
|
+
self._queue_out(proto.wire_port(port, data))
|
|
173
|
+
|
|
174
|
+
def send_frame(self, payload: bytes) -> None:
|
|
175
|
+
"""Send a complete payload (frame header included)."""
|
|
176
|
+
if len(payload) > proto.MAX_PAYLOAD:
|
|
177
|
+
raise ValueError(f'{len(payload)} bytes exceed the {proto.MAX_PAYLOAD}-byte frame limit')
|
|
178
|
+
self._queue_out(proto.wire(payload))
|
|
179
|
+
|
|
180
|
+
def recv(self, port: int, timeout: Optional[float] = None) -> Optional[bytes]:
|
|
181
|
+
"""The next payload received on `port`, or None after `timeout` seconds."""
|
|
182
|
+
with self._cv:
|
|
183
|
+
q = self._ports[port]
|
|
184
|
+
self._wait(lambda: q, timeout)
|
|
185
|
+
return q.popleft()[1] if q else None
|
|
186
|
+
|
|
187
|
+
def frames(self, timeout: Optional[float] = None, duration: Optional[float] = None) -> Iterator[bytes]:
|
|
188
|
+
"""Every received payload (frame header included, pulses excluded), until `timeout` seconds
|
|
189
|
+
pass without one, or `duration` seconds in all."""
|
|
190
|
+
return self._drain(self._frames, timeout, duration)
|
|
191
|
+
|
|
192
|
+
def logs(self, timeout: Optional[float] = None, duration: Optional[float] = None) -> Iterator[LogRecord]:
|
|
193
|
+
"""Decoded log records (and error records), until `timeout` seconds pass without one, or
|
|
194
|
+
`duration` seconds in all."""
|
|
195
|
+
return self._drain(self._logs, timeout, duration)
|
|
196
|
+
|
|
197
|
+
def _drain(self, q, timeout, duration):
|
|
198
|
+
end = None if duration is None else time.monotonic() + duration
|
|
199
|
+
while True:
|
|
200
|
+
t = timeout
|
|
201
|
+
if end is not None:
|
|
202
|
+
left = max(0.0, end - time.monotonic())
|
|
203
|
+
t = left if t is None else min(t, left)
|
|
204
|
+
with self._cv:
|
|
205
|
+
self._wait(lambda: q, t)
|
|
206
|
+
if not q or (end is not None and time.monotonic() >= end):
|
|
207
|
+
return
|
|
208
|
+
item = q.popleft()
|
|
209
|
+
yield item
|
|
210
|
+
|
|
211
|
+
def console(self, timeout: Optional[float] = None):
|
|
212
|
+
"""Semihost stdout/stderr output: (fid, bytes), or None after `timeout` seconds. Writes to
|
|
213
|
+
the same stream that have not been read yet are joined."""
|
|
214
|
+
with self._cv:
|
|
215
|
+
self._wait(lambda: self._console, timeout)
|
|
216
|
+
return self._console.popleft() if self._console else None
|
|
217
|
+
|
|
218
|
+
def command(self, obj: dict, timeout: float = 2.0):
|
|
219
|
+
"""Send a CBOR command map on port 0 and wait for the reply whose `rsp` equals its `cmd`.
|
|
220
|
+
Other port-0 frames stay queued for `recv(0)`. Returns the reply map, or None on timeout."""
|
|
221
|
+
if 'cmd' not in obj:
|
|
222
|
+
raise ValueError("a command map needs a 'cmd' key")
|
|
223
|
+
with self._cv:
|
|
224
|
+
after = self._seq
|
|
225
|
+
q = self._ports[proto.PORT_COMMAND]
|
|
226
|
+
self.send(proto.PORT_COMMAND, cbor.dumps(obj))
|
|
227
|
+
|
|
228
|
+
def find():
|
|
229
|
+
for i, (seq, data) in enumerate(q):
|
|
230
|
+
if seq <= after:
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
m = cbor.loads(data)
|
|
234
|
+
except cbor.CborError:
|
|
235
|
+
continue
|
|
236
|
+
if isinstance(m, dict) and m.get('rsp') == obj['cmd']:
|
|
237
|
+
return i, m
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
with self._cv:
|
|
241
|
+
found = None
|
|
242
|
+
deadline = time.monotonic() + timeout
|
|
243
|
+
while True:
|
|
244
|
+
found = find()
|
|
245
|
+
if found or self._closed:
|
|
246
|
+
break
|
|
247
|
+
left = deadline - time.monotonic()
|
|
248
|
+
if left <= 0:
|
|
249
|
+
break
|
|
250
|
+
self._cv.wait(left)
|
|
251
|
+
if not found:
|
|
252
|
+
if self._closed:
|
|
253
|
+
raise ConnectionClosedError(f'{self.address}: connection closed')
|
|
254
|
+
return None
|
|
255
|
+
del q[found[0]]
|
|
256
|
+
return found[1]
|
|
257
|
+
|
|
258
|
+
def close(self, flush_timeout: float = 2.0) -> None:
|
|
259
|
+
"""Close the connection, after sending what `send()` queued (waiting up to `flush_timeout`
|
|
260
|
+
seconds). A hub started for it by the library is stopped."""
|
|
261
|
+
if threading.current_thread() is not self._writer:
|
|
262
|
+
with self._out_cv:
|
|
263
|
+
self._out_cv.wait_for(lambda: (not self._out and not self._sending) or self._write_done,
|
|
264
|
+
flush_timeout)
|
|
265
|
+
with self._cv:
|
|
266
|
+
self._closed = True
|
|
267
|
+
self._cv.notify_all()
|
|
268
|
+
with self._out_cv:
|
|
269
|
+
self._out_cv.notify_all()
|
|
270
|
+
try:
|
|
271
|
+
self._sock.shutdown(socket.SHUT_RDWR)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
self._sock.close()
|
|
275
|
+
for t in (self._reader, self._writer):
|
|
276
|
+
if t is not threading.current_thread():
|
|
277
|
+
t.join(2)
|
|
278
|
+
self._semihost.close_all()
|
|
279
|
+
if self._hub is not None:
|
|
280
|
+
from .hub import _forget
|
|
281
|
+
self._hub.stop()
|
|
282
|
+
_forget(self._hub)
|
|
283
|
+
self._hub = None
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------ internals
|
|
286
|
+
|
|
287
|
+
def _wait(self, pred, timeout):
|
|
288
|
+
"""Wait (holding the lock) until pred() or timeout; raise if closed with nothing left."""
|
|
289
|
+
self._cv.wait_for(lambda: pred() or self._closed, timeout)
|
|
290
|
+
if not pred() and self._closed:
|
|
291
|
+
raise ConnectionClosedError(f'{self.address}: connection closed')
|
|
292
|
+
|
|
293
|
+
def _push(self, q, item, key):
|
|
294
|
+
if len(q) >= self._max:
|
|
295
|
+
q.popleft()
|
|
296
|
+
self.dropped[key] += 1
|
|
297
|
+
q.append(item)
|
|
298
|
+
|
|
299
|
+
def _queue_out(self, b):
|
|
300
|
+
if self._closed:
|
|
301
|
+
raise ConnectionClosedError(f'{self.address}: connection closed')
|
|
302
|
+
self._enqueue(b)
|
|
303
|
+
|
|
304
|
+
def _enqueue(self, b):
|
|
305
|
+
with self._out_cv:
|
|
306
|
+
self._out.append(b)
|
|
307
|
+
self._out_cv.notify()
|
|
308
|
+
|
|
309
|
+
def _write_loop(self):
|
|
310
|
+
last_tx = None
|
|
311
|
+
try:
|
|
312
|
+
# The first byte is 00: a hub closes raw TCP clients that start with anything else (§4.5).
|
|
313
|
+
self._sock.sendall(proto.PULSE) # wake (protocol.md §2.3)
|
|
314
|
+
last_tx = time.monotonic()
|
|
315
|
+
ready = last_tx + WAKE_GAP
|
|
316
|
+
while not self._closed:
|
|
317
|
+
with self._out_cv:
|
|
318
|
+
if not self._out:
|
|
319
|
+
self._out_cv.wait(max(0.0, last_tx + PULSE_INTERVAL - time.monotonic()))
|
|
320
|
+
batch = list(self._out)
|
|
321
|
+
self._out.clear()
|
|
322
|
+
self._sending = bool(batch)
|
|
323
|
+
now = time.monotonic()
|
|
324
|
+
if batch:
|
|
325
|
+
if now - last_tx > LIVENESS_TIMEOUT:
|
|
326
|
+
self._sock.sendall(proto.PULSE)
|
|
327
|
+
ready = time.monotonic() + WAKE_GAP
|
|
328
|
+
if time.monotonic() < ready:
|
|
329
|
+
time.sleep(ready - time.monotonic())
|
|
330
|
+
self._sock.sendall(b''.join(batch))
|
|
331
|
+
last_tx = time.monotonic()
|
|
332
|
+
with self._out_cv:
|
|
333
|
+
self._sending = False
|
|
334
|
+
self._out_cv.notify_all()
|
|
335
|
+
elif now - last_tx >= PULSE_INTERVAL:
|
|
336
|
+
self._sock.sendall(proto.PULSE)
|
|
337
|
+
last_tx = now
|
|
338
|
+
except OSError:
|
|
339
|
+
pass # the reader sees the connection end and marks it closed once all data is read
|
|
340
|
+
finally:
|
|
341
|
+
with self._out_cv:
|
|
342
|
+
self._write_done = True
|
|
343
|
+
self._out_cv.notify_all()
|
|
344
|
+
|
|
345
|
+
def _mark_closed(self):
|
|
346
|
+
with self._cv:
|
|
347
|
+
self._closed = True
|
|
348
|
+
self._cv.notify_all()
|
|
349
|
+
with self._out_cv:
|
|
350
|
+
self._out_cv.notify_all()
|
|
351
|
+
|
|
352
|
+
def _read_loop(self):
|
|
353
|
+
try:
|
|
354
|
+
while True:
|
|
355
|
+
d = self._sock.recv(65536)
|
|
356
|
+
if not d:
|
|
357
|
+
break
|
|
358
|
+
self._on_bytes(d)
|
|
359
|
+
except OSError:
|
|
360
|
+
pass
|
|
361
|
+
except Exception: # a bug here must not leave the script waiting forever
|
|
362
|
+
log.exception('uclog reader')
|
|
363
|
+
finally:
|
|
364
|
+
self._mark_closed()
|
|
365
|
+
|
|
366
|
+
def _on_bytes(self, d):
|
|
367
|
+
now = time.monotonic()
|
|
368
|
+
with self._cv:
|
|
369
|
+
if self._first_rx is None:
|
|
370
|
+
self._first_rx = now
|
|
371
|
+
self._last_rx = now
|
|
372
|
+
pos = None if self._replayed else self._find_pulse(d)
|
|
373
|
+
if pos is None:
|
|
374
|
+
self._feed(d)
|
|
375
|
+
else:
|
|
376
|
+
self._feed(d[:pos]) # backlog
|
|
377
|
+
self._replayed = True
|
|
378
|
+
self._device_rx = now
|
|
379
|
+
self._live = True
|
|
380
|
+
self._feed(d[pos:])
|
|
381
|
+
self._cv.notify_all()
|
|
382
|
+
|
|
383
|
+
def _feed(self, d):
|
|
384
|
+
for ev in self._scanner.feed(d):
|
|
385
|
+
if ev[0] == 'oversize':
|
|
386
|
+
self._error('framing_oversize',
|
|
387
|
+
f'framing: frame exceeded {proto.MAX_FRAME} bytes, discarded {ev[1]} bytes')
|
|
388
|
+
continue
|
|
389
|
+
enc = ev[1]
|
|
390
|
+
try:
|
|
391
|
+
payload = proto.cobs_decode(enc)
|
|
392
|
+
except proto.CobsError:
|
|
393
|
+
self._error('framing_invalid',
|
|
394
|
+
f'framing: invalid COBS ({len(enc)} bytes) near {bytes(enc[:16]).hex()}…')
|
|
395
|
+
continue
|
|
396
|
+
self._on_payload(payload)
|
|
397
|
+
|
|
398
|
+
def _find_pulse(self, d):
|
|
399
|
+
# The hub sends a pulse after the backlog replay (protocol.md §8). Frames carry both
|
|
400
|
+
# delimiters, so two frames are separated by exactly two 00s: a longer run of 00s holds a
|
|
401
|
+
# pulse, and so does a run of two at the start of the stream. The 00 that completes the run
|
|
402
|
+
# is the first byte after that pulse. The hub sends it only for the device (a frame, or a
|
|
403
|
+
# pulse while the device is alive), so it also shows that the device is open and talking.
|
|
404
|
+
for i, b in enumerate(d):
|
|
405
|
+
if b == 0:
|
|
406
|
+
self._zeros += 1
|
|
407
|
+
if self._zeros >= (2 if self._stream_start else 3):
|
|
408
|
+
return i + 1
|
|
409
|
+
else:
|
|
410
|
+
self._zeros = 0
|
|
411
|
+
self._stream_start = False
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def _record(self, target, level, file, line, text, error=None):
|
|
415
|
+
return LogRecord(time.monotonic() - self._start, level, target, file, line, text, error)
|
|
416
|
+
|
|
417
|
+
def _error(self, kind, text, target=None):
|
|
418
|
+
self._push(self._logs, self._record(target, 'ERROR', '<uclog>', '', text, kind), 'logs')
|
|
419
|
+
|
|
420
|
+
def _on_payload(self, p):
|
|
421
|
+
m = proto.mux_decode(p)
|
|
422
|
+
if m[0] == 'pulse':
|
|
423
|
+
return
|
|
424
|
+
if m[0] == 'port' and m[1] == proto.PORT_DEVICE_INFO:
|
|
425
|
+
self._on_device_info(m[2])
|
|
426
|
+
if not self._live:
|
|
427
|
+
return
|
|
428
|
+
if m[0] == 'port' and m[1] == proto.PORT_SEMIHOST:
|
|
429
|
+
self._on_semihost(m[2])
|
|
430
|
+
return
|
|
431
|
+
self._push(self._frames, p, 'frames')
|
|
432
|
+
if m[0] == 'error':
|
|
433
|
+
self._error('short_log', f'decode: {m[1]}')
|
|
434
|
+
elif m[0] == 'port':
|
|
435
|
+
self._seq += 1
|
|
436
|
+
self._push(self._ports[m[1]], (self._seq, m[2]), m[1])
|
|
437
|
+
elif m[1] == proto.TYPE_RES:
|
|
438
|
+
self._error('reserved_type', f'decode: reserved frame type 2 [{_hex(p)}]', m[2])
|
|
439
|
+
elif self._contexts:
|
|
440
|
+
pending = self._contexts[-1].pending # its logdata is still being resolved
|
|
441
|
+
if len(pending) >= self._max:
|
|
442
|
+
del pending[0]
|
|
443
|
+
self.dropped['logs'] += 1
|
|
444
|
+
pending.append(m)
|
|
445
|
+
else:
|
|
446
|
+
self._decode(m, self._decoders)
|
|
447
|
+
|
|
448
|
+
def _decode(self, m, decoders):
|
|
449
|
+
_, _, target, addr, args = m
|
|
450
|
+
ld = decoders.get(target)
|
|
451
|
+
if ld is None:
|
|
452
|
+
self._error('no_logdata',
|
|
453
|
+
f'decode: no logdata for target {target} (addr 0x{addr:08x}) [{_hex(args)}]', target)
|
|
454
|
+
return
|
|
455
|
+
try:
|
|
456
|
+
level, file, line, text = ld.decode(addr, args)
|
|
457
|
+
rec = self._record(target, level, file, line, text)
|
|
458
|
+
except DecodeError as e:
|
|
459
|
+
text = (f'decode: unknown format addr 0x{addr & ~3:08x} in {ld.source} [{_hex(args)}]'
|
|
460
|
+
if e.kind == 'unknown_format' else f'decode: {e}')
|
|
461
|
+
rec = self._record(target, 'ERROR', '<uclog>', '', text, e.kind)
|
|
462
|
+
except Exception as e: # a decoder bug must not stop the reader thread
|
|
463
|
+
log.exception('uclog decode')
|
|
464
|
+
rec = self._record(target, 'ERROR', '<uclog>', '', f'decode: {type(e).__name__}: {e}',
|
|
465
|
+
'format')
|
|
466
|
+
self._push(self._logs, rec, 'logs')
|
|
467
|
+
|
|
468
|
+
def _on_device_info(self, data):
|
|
469
|
+
try:
|
|
470
|
+
info = cbor.loads(data)
|
|
471
|
+
except cbor.CborError as e:
|
|
472
|
+
self._error('bad_cbor', f'port 62: invalid CBOR ({e}) [{_hex(data)}]')
|
|
473
|
+
return
|
|
474
|
+
if not isinstance(info, dict):
|
|
475
|
+
return
|
|
476
|
+
self._info = info
|
|
477
|
+
h = info.get('app_hash')
|
|
478
|
+
if not isinstance(h, bytes) or h == self._app_hash:
|
|
479
|
+
return # the same image: the decoding context carries on (§5.2)
|
|
480
|
+
self._app_hash = h
|
|
481
|
+
ctx = _Context(h)
|
|
482
|
+
self._contexts.append(ctx)
|
|
483
|
+
threading.Thread(target=self._resolve, args=(ctx,), daemon=True).start()
|
|
484
|
+
|
|
485
|
+
def _resolve(self, ctx):
|
|
486
|
+
"""Resolve the logdata of one context (its own thread: it may download), then decode the
|
|
487
|
+
logs of every context that is ready, oldest first."""
|
|
488
|
+
lds, source, notes = [], None, []
|
|
489
|
+
try:
|
|
490
|
+
lds, source, notes = self._resolver.resolve(ctx.app_hash)
|
|
491
|
+
except Exception as e: # e.g. a malformed logdata file: no logdata, but keep going
|
|
492
|
+
log.exception('uclog resolver')
|
|
493
|
+
notes = [f'logdata for app_hash {ctx.app_hash.hex()[:16]}…: {type(e).__name__}: {e}']
|
|
494
|
+
for n in notes:
|
|
495
|
+
log.warning('%s', n)
|
|
496
|
+
with self._cv:
|
|
497
|
+
try:
|
|
498
|
+
self.notes.extend(notes)
|
|
499
|
+
# A new image with no logdata gets no_logdata records, not the old image's decoders.
|
|
500
|
+
ctx.decoders = {ld.target: ld for ld in lds}
|
|
501
|
+
if lds:
|
|
502
|
+
log.info('logdata: %s', source)
|
|
503
|
+
self.notes.append(f'logdata: {source}')
|
|
504
|
+
else:
|
|
505
|
+
self.notes.append(f'logdata: none for app_hash {ctx.app_hash.hex()[:16]}…')
|
|
506
|
+
finally:
|
|
507
|
+
ctx.done = True
|
|
508
|
+
while self._contexts and self._contexts[0].done:
|
|
509
|
+
c = self._contexts.popleft()
|
|
510
|
+
self._decoders = c.decoders if c.decoders is not None else {}
|
|
511
|
+
for m in c.pending:
|
|
512
|
+
self._decode(m, self._decoders)
|
|
513
|
+
c.pending = []
|
|
514
|
+
self._cv.notify_all()
|
|
515
|
+
|
|
516
|
+
def _on_semihost(self, data):
|
|
517
|
+
r, console = self._semihost.handle(data)
|
|
518
|
+
if console and console[0] == 'stdin':
|
|
519
|
+
n = console[1]
|
|
520
|
+
if self._stdin is None:
|
|
521
|
+
self._enqueue(proto.wire_port(proto.PORT_SEMIHOST, self._semihost.stdin_reply(b'', n)))
|
|
522
|
+
else:
|
|
523
|
+
threading.Thread(target=self._answer_stdin, args=(n,), daemon=True).start()
|
|
524
|
+
return
|
|
525
|
+
if console:
|
|
526
|
+
last = self._console[-1] if self._console else None
|
|
527
|
+
if last and last[0] == console[0] and len(last[1]) < 4096:
|
|
528
|
+
self._console[-1] = (last[0], last[1] + console[1]) # e.g. unbuffered stderr, a byte per write
|
|
529
|
+
else:
|
|
530
|
+
self._push(self._console, console, 'console')
|
|
531
|
+
if r is not None:
|
|
532
|
+
self._enqueue(proto.wire_port(proto.PORT_SEMIHOST, r))
|
|
533
|
+
|
|
534
|
+
def _answer_stdin(self, n):
|
|
535
|
+
try:
|
|
536
|
+
line = self._stdin(n)
|
|
537
|
+
except Exception as e: # the script's callback failed: give the device EOF
|
|
538
|
+
log.warning('stdin callback: %s', e)
|
|
539
|
+
line = b''
|
|
540
|
+
if isinstance(line, str):
|
|
541
|
+
line = line.encode('utf-8')
|
|
542
|
+
self._enqueue(proto.wire_port(proto.PORT_SEMIHOST, self._semihost.stdin_reply(line or b'', n)))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class _Context:
|
|
546
|
+
"""Logs after one device info (with a new app_hash), waiting for that image's logdata."""
|
|
547
|
+
|
|
548
|
+
def __init__(self, app_hash):
|
|
549
|
+
self.app_hash = app_hash
|
|
550
|
+
self.pending = []
|
|
551
|
+
self.decoders = None
|
|
552
|
+
self.done = False
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# connect()
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
def _is_loopback(host):
|
|
560
|
+
try:
|
|
561
|
+
return ipaddress.ip_address(host).is_loopback
|
|
562
|
+
except ValueError:
|
|
563
|
+
return host == 'localhost'
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _hub_matches(h, device):
|
|
567
|
+
return device in (h.device, h.serial_number, h.name)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def connect(host: Optional[str] = None, device: Optional[str] = None, *, logdata=None,
|
|
571
|
+
files_dir=None, stdin=None, cache_dir=None, logdata_domain=None, logdata_url=None,
|
|
572
|
+
logdata_token=None, discover_remote: bool = False, approval_timeout: float = 60.0,
|
|
573
|
+
device_timeout: Optional[float] = 10.0, max_queue: int = 10000,
|
|
574
|
+
skip_backlog: bool = False, hub_args=()) -> Connection:
|
|
575
|
+
"""Connect to a hub, starting one if none is running.
|
|
576
|
+
|
|
577
|
+
- `host`: a hub's address ("192.168.1.20", "labpc.local:9001"); default $UCLOG_HOST.
|
|
578
|
+
- `device`: choose the hub (or start one) for this device: a URI (`serial:/dev/ttyACM0`) or a
|
|
579
|
+
serial number.
|
|
580
|
+
- `logdata`: a file or a list of files (`.logdata` or ELF). See `Resolver` for the order.
|
|
581
|
+
- `files_dir`: answer the device's file syscalls inside this directory (else OPEN and MKDIR get EACCES).
|
|
582
|
+
- `stdin`: callable(max_bytes) → bytes/str for the device's stdin reads (else EOF).
|
|
583
|
+
- `device_timeout`: how long to wait for data from the device itself (a frame, or a pulse the
|
|
584
|
+
hub sends only while the device is alive), after the hub's backlog replay, before raising
|
|
585
|
+
DeviceNotRespondingError. A hub opens a BLE device in about 5 s. None: don't wait.
|
|
586
|
+
- `approval_timeout`: for remote hubs, how long the hub may hold the connection while its
|
|
587
|
+
operator approves it (before `device_timeout` starts).
|
|
588
|
+
- `skip_backlog`: drop the frames the hub buffered before this connection.
|
|
589
|
+
- `cache_dir`, `logdata_domain`, `logdata_url`, `logdata_token`: the logdata cache and service
|
|
590
|
+
(default: $UCLOG_CACHE_DIR, $UCLOG_LOGDATA_DOMAIN, $UCLOG_LOGDATA_URL, $UCLOG_LOGDATA_TOKEN).
|
|
591
|
+
- `discover_remote`: also look for hubs on the network (mDNS; needs the `mdns` extra).
|
|
592
|
+
- `max_queue`: entries kept per queue (logs, frames, each port); the oldest are dropped and
|
|
593
|
+
counted in `dropped`.
|
|
594
|
+
- `hub_args`: more arguments for a hub this call starts (e.g. `["--bind", "0.0.0.0"]`).
|
|
595
|
+
"""
|
|
596
|
+
if isinstance(logdata, (str, os.PathLike)):
|
|
597
|
+
logdata = [logdata]
|
|
598
|
+
resolver = Resolver(logdata or (), cache_dir, logdata_domain, logdata_url, logdata_token)
|
|
599
|
+
manual = resolver.manual() # a bad path fails here, before a hub is started
|
|
600
|
+
host = host or os.environ.get('UCLOG_HOST')
|
|
601
|
+
hub = None
|
|
602
|
+
if not host:
|
|
603
|
+
hubs = list_hubs(discover_remote)
|
|
604
|
+
if device:
|
|
605
|
+
hubs = [h for h in hubs if _hub_matches(h, device)]
|
|
606
|
+
if len(hubs) > 1:
|
|
607
|
+
raise MultipleHubsError(
|
|
608
|
+
'several hubs are running; choose one with host= (or UCLOG_HOST):\n' +
|
|
609
|
+
'\n'.join(f' {h}' for h in hubs), hubs)
|
|
610
|
+
if hubs:
|
|
611
|
+
host = hubs[0].address
|
|
612
|
+
else:
|
|
613
|
+
if device and ':' in device:
|
|
614
|
+
uri = device
|
|
615
|
+
else:
|
|
616
|
+
devs = list_devices()
|
|
617
|
+
if device:
|
|
618
|
+
devs = [d for d in devs if device in (d.serial, d.uri, d.name)]
|
|
619
|
+
if not devs:
|
|
620
|
+
raise NoHubError('no hub is running and no device was found' +
|
|
621
|
+
(f' matching {device!r}' if device else ''))
|
|
622
|
+
if len(devs) > 1:
|
|
623
|
+
raise MultipleHubsError(
|
|
624
|
+
'several devices found; choose one with device=:\n' +
|
|
625
|
+
'\n'.join(f' {d}' for d in devs), devs)
|
|
626
|
+
uri = devs[0].uri
|
|
627
|
+
# Another script may be starting a hub for this device right now: take turns, then look
|
|
628
|
+
# again before starting one.
|
|
629
|
+
with spawn_lock(uri):
|
|
630
|
+
again = [h for h in list_hubs() if _hub_matches(h, uri)]
|
|
631
|
+
if again:
|
|
632
|
+
host = again[0].address
|
|
633
|
+
else:
|
|
634
|
+
hub = start_hub(uri, hub_args)
|
|
635
|
+
host = f'127.0.0.1:{hub.port}'
|
|
636
|
+
addr, port = parse_host(host)
|
|
637
|
+
try:
|
|
638
|
+
sock = socket.create_connection((addr, port), timeout=10)
|
|
639
|
+
except OSError as e:
|
|
640
|
+
if hub:
|
|
641
|
+
hub.stop()
|
|
642
|
+
raise NoHubError(f'cannot connect to the hub at {addr}:{port}: {e}') from None
|
|
643
|
+
sock.settimeout(None)
|
|
644
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
645
|
+
local = _is_loopback(sock.getpeername()[0])
|
|
646
|
+
c = Connection(sock, resolver=resolver, files_dir=files_dir, stdin=stdin, max_queue=max_queue,
|
|
647
|
+
skip_backlog=skip_backlog, hub=hub, address=f'{addr}:{port}', manual=manual)
|
|
648
|
+
busy = (f'{addr}:{port}: the hub closed the connection: another client is connected (one '
|
|
649
|
+
'client at a time), or the hub refused this one')
|
|
650
|
+
if not local and approval_timeout is not None:
|
|
651
|
+
# A hub sends its replay (at least the end-of-backlog pulse) as soon as it accepts us.
|
|
652
|
+
with c._cv:
|
|
653
|
+
c._cv.wait_for(lambda: c._first_rx is not None or c._closed, approval_timeout)
|
|
654
|
+
got, closed = c._first_rx is not None, c._closed
|
|
655
|
+
if not got:
|
|
656
|
+
c.close(0)
|
|
657
|
+
if closed:
|
|
658
|
+
raise HubBusyError(busy)
|
|
659
|
+
raise ApprovalTimeoutError(f'{addr}:{port}: no data within {approval_timeout} s; the hub '
|
|
660
|
+
'may be waiting for its operator to approve this connection')
|
|
661
|
+
if device_timeout is None:
|
|
662
|
+
return c
|
|
663
|
+
# The end-of-backlog pulse arrives at once; wait for the device itself (protocol.md §8). A
|
|
664
|
+
# hub that has just started may still be opening it.
|
|
665
|
+
c._device_deadline = time.monotonic() + device_timeout
|
|
666
|
+
with c._cv:
|
|
667
|
+
c._cv.wait_for(lambda: c._device_rx is not None or c._closed, device_timeout)
|
|
668
|
+
got, closed, any_rx = c._device_rx is not None, c._closed, c._first_rx is not None
|
|
669
|
+
if got or (closed and any_rx):
|
|
670
|
+
return c # (a closed connection still returns what it received)
|
|
671
|
+
c.close(0)
|
|
672
|
+
if closed:
|
|
673
|
+
raise HubBusyError(busy)
|
|
674
|
+
raise DeviceNotRespondingError(
|
|
675
|
+
f'{addr}:{port}: no data from the device within {device_timeout} s' +
|
|
676
|
+
(' (the hub is connected, but has not opened the device or the device is silent)'
|
|
677
|
+
if any_rx else ''))
|