Skip to content

Python

The uclog package is for tests and scripts: read decoded logs, send to the terminal and command ports, answer the device's file calls. It talks to devices through a hub, and starts one for you (the wheel includes uclog-hub).

pip install uclog
import uclog

with uclog.connect(logdata="build/app.elf") as dev:
    print(dev.device_info)                    # board, app_hash, ...
    dev.send(61, "help\r")                    # str or bytes, to any port
    print(dev.recv(61, timeout=1.0))          # the next payload on port 61, or None
    rsp = dev.command({"cmd": "get-info"})    # port 0: a CBOR request, and its reply
    for rec in dev.logs(duration=5.0):        # decoded logs for 5 s
        print(rec)                            # 1.234:INFO:src/main.c:10:Hello World!

Which device

uclog.connect(host=None, device=None, ...):

  1. With host (or $UCLOG_HOST): that hub, such as "lab-pc.local:9001".
  2. Otherwise a hub already running on this machine (or, with discover_remote=True, on the network); with device= (a URI or a serial number), that device's hub.
  3. Otherwise it starts a hub for the one local device, or the one device= names. The hub listens on 127.0.0.1 only and stops when the connection closes, or when the script dies.

It never guesses: several matches raise MultipleHubsError, with the candidates. A hub with a client already raises HubBusyError; one waiting for its operator's approval raises ApprovalTimeoutError after 60 s. connect() returns once the device has sent something (DeviceNotRespondingError after 10 s).

The connection

logs(timeout=, duration=) decoded LogRecords: ts, level, file, line, text, and error for records that could not be decoded
send(port, data), recv(port, timeout=) any port; at most 1443 bytes per send
command(obj, timeout=2.0) a CBOR request on port 0, and the reply whose rsp matches its cmd
console(timeout=) the device's stdout and stderr (semihost)
device_info the device's port-62 information
frames() every payload, undecoded

On connect, a hub first replays what the device sent while no one was connected; skip_backlog=True drops that. uclog.aio.connect() is the asyncio version.

Logdata and files

logdata= takes files (an ELF or .logdata); then the shared cache, then the logdata service with logdata_domain= and logdata_token= (or UCLOG_LOGDATA_DOMAIN, UCLOG_LOGDATA_TOKEN). See Logdata.

The script answers the device's semihost calls: stdout and stderr go to console(), stdin reads call stdin=, and file calls work inside files_dir= only (without it they fail with EACCES).

Every function and class: the Python reference.

Examples

In python/examples/; the tests run each of them against the simulated device.

Example
capture_logs.py logs as they arrive, filtered by level, source and text
command.py a CBOR request on port 0 and its reply
terminal.py an interactive terminal on any OS; input can be piped
decode_capture.py decode a raw capture of a device's bytes, with no device or hub
pytest/ a pytest fixture for hardware tests, and tests that use it

Hardware tests with pytest

conftest.py connects once for the run, to the device given with --uclog-device (or $UCLOG_DEVICE, or the one local device), and gives the tests dev; no_errors fails a test in which the device logs an error:

pytest python/examples/pytest --uclog-device serial:/dev/ttyACM0 --uclog-logdata build/app.elf
# © 2026 Unit Circle Inc.
# SPDX-License-Identifier: Apache-2.0

"""A pytest fixture for hardware tests: `dev`, one connection to the device for the whole run.

    pytest examples/pytest --uclog-device serial:/dev/ttyACM0 --uclog-logdata build/app.elf
    pytest examples/pytest --uclog-device loopback:rate=20 \\
        --uclog-logdata ../spec/vectors/sample.logdata       # the simulated device

Without --uclog-device it uses $UCLOG_DEVICE, else the one local device (as `uclog.connect()`).
A hub is started for the run and stopped at the end.
"""

import os

import pytest

import uclog


def pytest_addoption(parser):
  g = parser.getgroup('uclog')
  g.addoption('--uclog-device', help='device URI or serial number (default: $UCLOG_DEVICE, else the one local device)')
  g.addoption('--uclog-host', help='a hub to use, instead of finding or starting one')
  g.addoption('--uclog-logdata', action='append', help='.logdata or ELF file (repeatable)')


@pytest.fixture(scope='session')
def dev(request):
  o = request.config.option
  device = o.uclog_device or os.environ.get('UCLOG_DEVICE')
  with uclog.connect(o.uclog_host, device, logdata=o.uclog_logdata, skip_backlog=True) as d:
    yield d


@pytest.fixture
def no_errors(dev):
  """Fails the test if the device logs an ERROR (or worse) while it runs, or a log cannot be
  decoded."""
  for _ in dev.logs(timeout=0):  # what came before the test is not its business
    pass
  yield
  bad = [r for r in dev.logs(duration=0.5) if r.error or r.level in ('ERROR', 'FATAL', 'PANIC')]
  assert not bad, '\n'.join(map(str, bad))
# © 2026 Unit Circle Inc.
# SPDX-License-Identifier: Apache-2.0

"""Hardware tests with the `dev` fixture (conftest.py). These hold for any ucLog device; add your
own: commands and their replies, logs a command should produce, limits on values in logs."""

import re

import pytest


def test_device_info(dev):
  info = dev.device_info
  assert info['board']
  assert len(info['app_hash']) == 64


def test_logs_decode(dev, no_errors):
  recs = list(dev.logs(duration=3.0))
  assert recs, 'no logs in 3 s'
  assert all(r.error is None for r in recs), [str(r) for r in recs if r.error]


def test_terminal_answers(dev, no_errors):
  dev.send(61, 'help\r')
  reply = dev.recv(61, timeout=2.0)
  assert reply, 'no reply to help on the terminal'


def test_values_in_logs(dev):
  # Checking what a device reports: every `temp=` value above a limit. Skipped when the device
  # logs no temperatures.
  temps = [int(m[1]) for r in dev.logs(duration=3.0) if (m := re.search(r'temp=(-?\d+)', r.text))]
  if not temps:
    pytest.skip('no temp= logs from this device')
  assert min(temps) > -40, temps

Decoding a capture

#! /usr/bin/env python3
# © 2026 Unit Circle Inc.
# SPDX-License-Identifier: Apache-2.0

"""Decode a raw capture of what a device sent (no device or hub needed), with its logdata.

    python decode_capture.py -l build/app.elf capture.bin
    cat /dev/ttyACM0 > capture.bin       # a capture: the bytes as they came off the link
    python decode_capture.py -l build/app.elf - < capture.bin

Logs are printed as decoded; terminal text (port 61) is printed as it is; other ports are summed
up at the end. `uclog decode` does the same from the command line.
"""

import argparse
import collections
import sys

import uclog
from uclog import proto

ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('capture', help="the captured bytes, or - for stdin")
ap.add_argument('-l', '--logdata', action='append', required=True, help='.logdata or ELF file (repeatable)')
a = ap.parse_args()

# Logdata by target digit: a bootloader and an app can log on the same link.
decoders = {ld.target: ld for ld in map(uclog.LogData.load, a.logdata)}
data = sys.stdin.buffer.read() if a.capture == '-' else open(a.capture, 'rb').read()

ports = collections.Counter()
for event in proto.FrameScanner().feed(data):
  if event[0] != 'frame':
    print(f'<frame of {event[1]} bytes dropped: too long>')
    continue
  try:
    payload = proto.cobs_decode(event[1])
  except proto.CobsError:
    print(f'<invalid frame: {event[1][:16].hex(" ")}…>')
    continue
  m = proto.mux_decode(payload)
  if m[0] == 'log':
    _, _, target, addr, args = m
    ld = decoders.get(target)
    if ld is None:
      print(f'<no logdata for target {target}>')
      continue
    try:
      level, file, line, text = ld.decode(addr, args)
      print(f'{level}:{file}:{line}:{text}')
    except uclog.DecodeError as e:
      print(f'<{e.kind}: {e}>')
  elif m[0] == 'port' and m[1] == proto.PORT_TERMINAL:
    sys.stdout.write(m[2].decode('utf-8', 'replace'))
  elif m[0] == 'port':
    ports[m[1]] += 1

for port, n in sorted(ports.items()):
  print(f'port {port}: {n} frames')