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/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 ''))