OEP Python client
The host side of Open Embedded Probe (OEP). It speaks the v1 protocol of
oep-spec (the core docs/oep-core.ja.md, docs/oep-transports.ja.md
and the interfaces interfaces/*.ja.md), v1 before the freeze: until the freeze the spec may still break. The wire numbers come from oep_client.registry, a verbatim copy of
oep-spec's generated generated/oep-v1/oep_v1_registry.py. This is an experimental stage: breaking changes are expected and
no compatible API is promised.
The spec this implements: oep-spec commit 67876b7 (no v0.x tag yet; oep-spec versioning §2 / §6 - before the
freeze, revision 1 alone does not fix the forms, so an implementation names the spec commit it implements). The registry
and the shared vectors in tests/vectors/ are copied from that commit (tests/vectors/SPEC_COMMIT); what changed on the
way to it is in CHANGELOG.md.
Until the freeze the Japanese text (.ja.md) is the specification's working text; the English documents are produced
from it at the freeze and become authoritative then (oep-spec keeps no English translations until then). Start with oep-spec's README and
review guide (what is where); getting started
builds the smallest probe and host, docs/oep-core.ja.md is the protocol core, and
docs/conformance.ja.md says what a host must do to conform.
It follows OEP's division of work: the knowledge of the target lives in the host. The probe knows only its wires and DMI / DP-AP transfers; the CH32 flash controller, the RAM loader, the RP2350 boot ROM, the Cortex-M debug registers and so on are here.
pip install oep-client-python # PyPI (import oep_client); a checkout: pip install -e <checkout>
uv run pytest # in a checkout (the virtual bench; no hardware)
OEP_HW_BOARDS=<board id> OEP_HW_NOFLASH=1 uv run pytest tests/hw -m hw # a real probe: tests/hw/README.md
import oep_client is all it takes. The registry and oep-spec's test vectors (tests/vectors/*.json, checked by
tests/test_spec_vectors.py against this client and the virtual bench) are copied from a named oep-spec checkout with
OEP_SPEC_DIR=<checkout> tools/sync_registry.sh (OEP_SPEC_REF picks a commit other than its HEAD; the commit and the
files' hashes go to tests/vectors/SPEC_COMMIT / SPEC_SHA256). PyPI's oep-client is another project, so the
distribution is named oep-client-python.
Releases: run the GitHub Actions workflow Release (workflow_dispatch, version X.Y.Z or X.Y.ZbN). tools/prepare_release.py
sets the version in pyproject.toml, uv.lock and oep_client.__version__ and turns CHANGELOG.md's Unreleased into that version; after
the tests and the build it commits, tags, makes the GitHub Release and publishes to PyPI (Trusted Publishing). Record changes
under Unreleased in CHANGELOG.md, (EN) and (JA).
A minimal check: hardware quickstart. See the shared testing policy and guide for projects using OEP. The hardware design and configuration examples describe the planned format; its offline validator/planner is available; integration with the hardware runner is pending. These working documents are in Japanese.
Modules (oep_client)
The modules below are the public API; import them directly (from oep_client import riscv). Anything not listed, and
names starting with _, may change. Check probe/client compatibility using the implemented SPEC version, protocol/interface revisions, required declarations and integration tests. Record firmware and client versions separately; matching version strings alone do not establish compatibility. Before the v1 freeze the wire may change, so name the implemented SPEC and tested combinations.
| Module | Contents |
|---|---|
host |
requests and results (one 10-byte header: the session's id on a request sent in the session, 0 on a lock-free one outside it; corr cyclic, never 0, one counter for every session id), the session id and the lock, call() (raises unless it worked), pipelining, the errors (OepError / Rejected / Failed; NoSession when the session ended - end, lease expiry, another session's force - and the probe released everything it created: no resume, the session is never re-opened behind the caller's back, Host.session goes to None and Host.epoch moves; NoResource for a connection or stream number the probe does not know; NotUsable for a probe whose confirm is outside core §7.1's bounds or whose max_op_ms is outside 1..600000 - nothing more is sent to it; Unavailable reads the payload's cause, channels and fn, the fn the refusal is about). open(lease_ms, force=, owner=) always opens a new session under a new random id and answers Opened(lease_ms, boot_id); end() releases everything and leaves the host out of a session. A changed boot_id (confirm, clock, open) drops the name -> fn cache, so interfaces are listed again. clock() reads fn 0's clock (core §7.6, lock-free and session-free) as ClockReading(before_ns, after_ns, uptime_ns, boot_id, round_trip_ns) - this host's time (time.monotonic_ns, or now=) just before the request went out and just after the answer came in, the probe's uptime read between them; .host_ns is the midpoint and .uncertainty_ns half the round trip (host guide §12) - and clock_best(n=8) keeps the shortest round trip of n readings. subscribe(fn, min_bytes, max_delay_ms) / unsubscribe(fn) send the interface's own ops 0x01 / 0x02 (core §10.3; min_bytes / max_delay_ms batch the data only, events come at once; an fn that sends nothing answers unknown_operation; fn 0 sends none, so subscribe(0) raises ValueError before sending). restart_probe(wait_s=None, reopen_s=None) restarts the probe through oep.probe.restart, found by name (oep-if-restart, host guide §5.2; LookupError when the probe lists none; the session must hold the lock): after the answer the link closes, waits a moment (about 100 ms, host.RESTART_AFTER_ANSWER_S), opens again as a new open (confirm first; a serial port at its boot speed, a USB device found again once it re-enumerated, a TCP connection made again), retried for wait_s, by default the probe's restart_max_ms (oep.probe.restart's describe, read before the restart: the probe's own restart only; core.restart_max_ms) plus host.RESTART_EXTERNAL_S (5 s) for enumeration and reconnection, or 10 s when it declares none - then the probe is gone: the link is closed and ConnectionError / TimeoutError raised; reopen_s is the user's reopen of such a probe, asked for beforehand: opened again as a new open for up to that much more (a host where the device returns later than the probe can know - WSL, where usbipd attaches the re-enumerated device again; Host.restart_reopened says it was needed) - and the new boot_id is returned - NotRestarted if it stayed the same; a lost answer whose resend the restarted probe refuses no_session counts as done. request_restart() sends the request alone. |
link |
transports: serial ports (always COBS + CRC as 0x00 <COBS> 0x00, bytes outside frames skipped as noise, opened exclusively, 8N1 with DTR / RTS asserted), USB vendor bulk / HID and TCP (length frames, the transports §5 resync - waiting longer than probe_frame_gap_ms after the host's last write, 250 ms; on TCP a pause inside a frame is read on); matching by corr and resending (on a serial port a session holds, a broken frame while an answer is awaited is resent at once with the same corr, up to BROKEN_RESENDS = 3 times while frames keep arriving - host guide §8; a wait with nothing allows the one resend; at a raised port_speed the resend goes at that rate first, below); a resend that gets no answer either raises TransportFailed, and the next request first recovers with a confirm (quiet input, then a confirm with its own corr; serial ports too) or raises ConnectionError; every answer waited at least core §4.4's floor (the request's processing bound - max_op_ms, fn 0's confirm / list / describe / clock / keepalive / lock_state at most 1000 ms, 1000 ms before max_op_ms is read, an argument's longer wait kept, Host.op_bound_ms - + 1000 ms + a serial port's transfer time with min_max_frame until a confirm answer, wait_floor_s; Host.open reads fn 0's describe first; the link's own port_speed confirms and revert keep their own short waits); short answers are broken frames; open_host(target) |
kept_session |
the session id a host keeps per probe for its next run (KeptSession, Host.kept; open_host sets it; below) |
discovery |
probes on the network: DNS-SD _oep._tcp over mDNS (transports §3) - browse(timeout) -> Found(instance, unit_id, host, port, addresses), verify(found) / check(f) (confirm + describe's unit_id, host guide §4.1), find_unit(unit_id) (verified), port_of(host); python-zeroconf when installed (the mdns extra), else a minimal query of its own (below) |
core |
interfaces by name (cached), confirm (with the probe's boot_id and transport, the index this host came in on; later confirms ask for the revision in use), the probe's describe (declarations only, cached per boot: labels, the transport list, max_op_ms), every fn's ops (the describe's ops tag, core §7.4 - base and a bitmap of 1 byte or more, not past op 0xFF - its form checked: an fn whose ops break it is not used - UnusableFunction -, fn 0's makes the probe NotUsable; ops(hst, fn), offers(hst, fn, op), Interface.offers(op); require / not_offered raise without sending the same Rejected (detail unknown_operation) the probe would answer), taking the lock (take), the pin plan through oep.probe.plan (plan_fn, plan_apply, plan_release, plan_roles), oep.probe.restart (restart_fn, restart_max_ms), the Interface base; list_entries(hst, name, exact) pages list by first alone and keeps the names that match on this host (core §7.2); a list entry with fn 0 is left out (the core is never listed); max_op_ms (FALLBACK_MAX_OP_MS 10000 for a probe that declares none); oep.probe.link (oep-if-link): link_fn, link_speed, source / sink request and answer helpers (link_size = max_frame - 7 a source answer, link_sink_size = max_frame - 12 a sink request) |
riscv |
oep.wire.rvswd / oep.wire.swio (scan - the host builds the candidate pairs, candidate_pairs() -, attach - method, the pin pair and max_speed in the fixed part, always a new connection (busy pins unavailable), reset=(channel, hold_ms) for an attach under reset, method 1 (halt) only with features attach_halt -, detach, connections - u32 connection ids), oep.target.riscv-dm (answers count their values; reset() -> (flags, pc) - mode 0, flags bit0 reached -, reset_halt() -> dpc; RunResult.not_halted, RunResult.not_run - the preparation failed and the hart was not run; a step that could not halt the hart again raises StepError with step_left; read_register / write_register keep DATA0 as it was (data_saved) and resume / step / run write it back first (restore_data, debug §4)), RiscvDm.declared() (the optional ops the probe offers by its ops tag; the others answer unknown_operation), a high-level op whose cleanup failed (step_left, a block's DATA restore) leaves the connection closed, target_id scheme dmi_7f (Wire.SCHEME_DMI_7F), attach, scan and a riscv-dm reset wait max_op_ms, their argument time (attach_ms, scan_ms, reset_ms; debug §1, §4.3), finding the reset line (find_reset_line(candidates, pins=...)), attach through GPIO |
targets |
what the host knows per target family, in one table (FAMILIES: wire, target_id match, reset vector, option-byte NRST reader, max_speed / idle_clock); identify(target_id) |
pins |
oep pins: classify the channels, hold-low search, scan, identify, confirm the reset line, suggest the connection profile and the labels (PinFinder) |
console |
oep.target.console (position streams: read answers carry their length; marks carry time_ns, the common kinds lost / clear / host and the console's reset 0x40 / restart 0x41; the lock-free streams() list of stream, connection and mechanism; open(conn, mechanism) always makes a new stream (u32) on the connection - a second on a connection with a live one is unavailable cause 6 -; a stream is the session's - close, its connection's release and the session's end release it with its buffer, and its number answers no_resource after that; a write goes into the stream's send queue - its size is the probe's own, not declared -, accepted = min(count, its free space), 0 only when full; an SDI stream's write is unsupported; marks, clear, mark and write are optional) and ConsoleIO, read as bytes and written in chunks of at most one frame, on from each answer's accepted (stall_s: TimeoutError when nothing is taken for that long) |
fixture |
oep.fixture.gpio (set([(ch, mode)]) applied in order in one request, read(channels) the levels; the output strength is the probe's own - there is no drive selection) / uart (its stream is the plan's; nothing is received until configure(baud, fmt); status() = UartStatus(baud, format) in force, baud 0 and format 0 while not configured; a configure the probe could not bring up raises Failed and leaves it not configured) / i2c-target (one form: configure(address) - the target made anew -, every controller write with data one frame for read_rx, reads answered from preload_tx(data) slots or 0xFF, status() = I2cStatus(state, queued, rx_frames, tx_slots, errors), stretch when the ops offer it, internal_pullups: whether it enables pull-ups of its own; the reserved addresses 0x00-0x07 / 0x78-0x7F refused before sending) / spi-target (no reset: configure again; cs_setup_ns: how long after CS the first bit is sure; oep dump shows it) |
config |
oep.probe.config (label / idle / disable items keyed by channel, get / set / unset with counts, save / erase / state when the probe has storage; describe() = the declarations, state() = State(storage, saved_generation, reason); the generation counts the changes of a boot from 1: same_items(a, b) compares items, needs_save() says whether a save would change the storage, apply(wanted, save=False) sets / unsets what differs (host guide §15); find_line(cfg, "nrst", profile=None) = host guide §5.3's line lookup, the firmware's fixed labels as its last step) |
capture |
oep.fixture.logic / analog / capture-group (revision 1, oep-spec oep-if-capture). configure's TLVs follow core §2.3: a value the probe cannot honour is refused unsupported with the tag as sent (the table's TLVs go without the critical bit); the answer holds the actual values, its samples among them (analog: a scale for every role, a skew left out read as 0). Every start() returns a new generation (LogicCapture.generation, never re-used within a boot: past 0xFFFFFFFF a start is refused until a reboot) that read and release name - read_segment(segment) passes the segment's own - and every event carries (events() drops an earlier start's); streaming data frames carry their generation and their segment (segment_info, never crossing a segment: Received.segments); status() returns a Status. A capture-group binds configured tracks with an immediate trigger, all in one mode, and starts them at once (CaptureGroup.start -> the group's start_ns; the group's and each track's generation). Every segment read goes to the Host.on_capture callbacks as a CaptureRecord (the hook for run recorders; no wireskein dependency) |
decode |
decoding capture channels (I2C) |
registry |
generated from oep-spec's number table (never edited; copied again from oep-spec). The public way to reach an interface by name is registry.INTERFACES[name] (.revision, .op, .tlv, .enum, e.g. INTERFACES["oep.fixture.uart"].enum["role"]); the module-level names (FIXTURE_UART, ...) are the same objects |
arm |
oep.wire.swd, oep.target.arm-adi, MEM-AP, halting and calling functions on a Cortex-M |
ch32_flash |
writing a CH32 (a RAM loader, page by page) |
rp2350 |
flash and reboot through the RP2350 boot ROM |
uiapduino |
into and out of the UIAPduino bootloader |
catalog / names / interfaces / dump |
the capability list and describe shapes, display |
virtual_bench / endpoint / virtual_bench_serial / virtual_bench_serve / virtual_bench_mdns |
the virtual bench (below) |
Example
from oep_client import core, link, riscv, ch32_flash
hst = link.open_host("/run/board-identify/by-id/esp32-series-30eda0e31108") # pipelined
# a serial port (always COBS), "tcp://127.0.0.1:PORT" (a broker), "usb:<unit id>" (the device whose USB serial it is;
# describe's unit_id must match), "usb" (every device on the project's VID:PID 1209:4F45, each counted once: exactly
# one -> vendor, then HID, else its CDC port; several -> link.SeveralProbes lists them, name one) /
# "usb:VID:PID[:SERIAL]" (vendor, then HID). Each is probed first with a
# confirm only (transports §3): no valid answer -> closed, link.NotOepProbe
core.take(hst, 30000, owner="flash script") # the only way in: force; else wait out the lease, name the holder
wire = riscv.Wire(hst, "oep.wire.rvswd")
conn, _ = wire.attach(halt=True)
dm = riscv.RiscvDm(hst, conn)
dm.reset_halt()
result = ch32_flash.program(hst, dm, open("sketch.bin", "rb").read(), ch32_flash.PROFILES["x035"])
dm.reset() # ndmreset and let it run -> (flags, pc)
wire.detach(conn)
hst.end()
USB access on Linux (udev)
A probe enumerates with the project's USB VID:PID 1209:4F45 (transports §3). Opening its vendor bulk (libusb) or its HID
(hidraw) as an ordinary user needs a udev rule: udev/70-oep-probe.rules (in a checkout and the sdist) gives the
logged-in user (uaccess) and the plugdev group access to the USB device and its hidraw node. Installing it needs
administrator rights:
sudo install -m 0644 udev/70-oep-probe.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger # or unplug and replug the probe
A probe's CDC ports (/dev/ttyACM*) need no rule beyond the usual dialout group. Windows and macOS need no rule.
The oep command
oep dump --port <probe> # what the probe offers (--virtual p4-x035: no hardware)
oep config show <probe> # the settings, the declarations and the storage state
oep config state <probe> # the storage state alone (lock-free)
oep config label <probe> 23 nrst # also: idle CH MODE, disable CH..., remove KIND CH (each takes --save)
oep config save <probe> # kept over a restart (also: erase)
oep speed <probe> [921600,500000] [--verify] [--flows out:2] # port_speed: raise a UART bridge (default 500000), print the report (below)
oep pins <probe> --power 5 --wire swio # where the target is wired: debug pins, reset line, labels (below)
oep clock <probe> [-n 8] [--json] # fn 0's clock: uptime_ns and boot_id against this host's time (shortest of n)
oep restart <probe> [--reopen-s S] # oep.probe.restart: restart the probe, wait until it is back (new boot_id);
# --reopen-s: not back within restart_max_ms (WSL / usbipd) - open it again for up to S s
oep find [--timeout 2] [--json] [--no-verify] # probes announcing _oep._tcp (each verified): unit_id, host, port, address
oep linktest <probe> ... # --timeout: 0.3 s an answer by default, 3 s over TCP
<probe> is a serial port, tcp://HOST:PORT, tcp://HOST (the port DNS-SD finds for that host), tcp:UNIT_ID (the
probe DNS-SD finds by its TXT unit_id; describe's unit_id must say the same, else it is closed) or
usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep config") and
ends the session after it; it takes effect at once and, after save, stays over a restart.
A host run again: the kept session id
A probe keeps a session when its transport closes (transports §3), so a command that was killed, crashed or lost its line
leaves its lock and what it held (connections, plan, subscriptions) until the lease runs out. link.open_host therefore
keeps the id of the session the host opens in a file per probe (kept_session.KeptSession as Host.kept; host guide §5),
and the host's first open ends the session a previous run left there - it opens that id (the holder's open again keeps
the session, core §6.2: nothing released) and ends it at once - before opening its own. Both requests go with corrs
after every one the previous run sent (core §5.1): the file keeps, with the id, a corr reserved ahead of what that run
used (renewed 1024 at a time), and the next run goes on after it; a file without one ends nothing. The oep command and
tests/hw open through open_host, so they do this; another long-lived host passes keep_session=True (the default) or
sets hst.kept = kept_session.KeptSession() on a Host it builds itself.
- The file is
<dir>/<unit_id>.session(the id and the reserved corr in hex,ssssssss cccc; empty afterend), keyed by fn 0's unit_id - the same probe on another port or transport is the same file.<dir>is$OEP_SESSION_DIR, else$XDG_RUNTIME_DIR/oep-client, else%LOCALAPPDATA%\oep-client\sessions(Windows) or${XDG_CACHE_HOME:-~/.cache}/oep-client/sessions. - While a host keeps the file it holds an exclusive lock on it (flock / msvcrt.locking), dropped when the link closes or the
process ends. A file another running host holds is left alone: that host's session is its own, and this host opens as
usual (it meets that lock,
core.takewaits or names the holder). Anx-unit_id names no unit: nothing is kept. Host.end_previous(sid)is the step itself: True when that session was ended, False when another session holds the lock (not this host's to end).keep_session=Falsereads and writes nothing.
TCP
A probe that serves OEP over TCP is found by DNS-SD (its network settings are not oep.probe.config's).
oep findbrowses_oep._tcp(transports §3, host guide §4.1) and lists unit_id, host, port and address. The service nameoepis not registered, so another service may advertise_oep._tcp: each instance is verified first (discovery.verify, all at once, 1 s per answer,--verify-timeout): a TCP connection, confirm (anOEP!answer) and fn 0's describe, whose unit_id must be the TXT unit_id - no session is opened. One that fails is dropped with a warning on stderr naming its endpoint and what it said;--no-verifylists every instance as announced.discovery.find_unit(and sotcp:UNIT_ID) verifies the same way and passes over an instance it cannot verify;open_hostthen checks confirm and describe's unit_id again on the link it opens. The port is always the SRV record's (none is fixed - the reference probe's 7450 is an example). With python-zeroconf installed (pip install 'oep-client-python[mdns]') it browses with it (every interface: InterfaceChoice.All), else with a minimal query of this package's own (QU, answers to an ephemeral port, a shared 5353 socket when it can bind one), sent out of every IPv4 interface (IP_MULTICAST_IF per interface address;discovery.interface_addresses: ifaddr when installed, else each interface's address on Linux, else the host name's addresses) - one send leaves by one interface only, on Windows often a virtual adapter's, and a probe on another adapter never hears it. mDNS stays on the local link: from WSL 2's default NAT network, a VM or across a router nothing is found - name the probe astcp://HOST:PORT.tcp:UNIT_IDopens the probe DNS-SD finds by that unit_id and checks describe's (UnitIdMismatch, closed, otherwise); after a restart it browses again (the address may change). TCP has no authentication: use it on a trusted network or a tunnel (transports §1).oep linktestwaits 3 s an answer over TCP (0.3 s elsewhere): Wi-Fi retransmissions stall answers for 1-4 s now and then, and they still come.
A run on hardware: ArduinoCore-CH32's tests/manual/oep_smoke/ (oep_smoke.py, oep_probe_checks.py).
Finding the pins (oep pins)
oep pins <probe> [--power CH] [--exclude CH,...] [--wire swio|rvswd|swd] [--steps ...] [--save] [--json] finds where a
target is wired to a probe whose pins the host chooses. Each step says what it did; the whole run stays under a minute and
every plan is released at the end. The procedure is this client's own (oep-spec's host guide §5.3 says only how a line
is looked up by its label); what each target family needs is in one table, targets.FAMILIES (wire, reset vector, the
option-byte reader for the reset line, max_speed / idle_clock).
- classify: every channel
oep.fixture.gpioallows is read 16 times under pull-up, then pull-down: floating (1 under pull-up, 0 under pull-down - a pull weaker than the probe's, such as a reset line's weak pull-up, reads so too), driven-high / driven-low (the same level under both pulls: push-pull or a strong pull - an idle-high UART line looks like this), active (changes between reads). With--power CHthe target is first read switched off: the channels that read otherwise follow its power. These are candidates only. - hold: each floating channel is held low (open drain) while an active channel is watched; a channel that stops it is a reset-line candidate.
- scan: the wire over the floating channels (rvswd / swd: pairs, at most 600) - never over a driven or active
channel, the power channel or
--exclude. - identify: attach (halt) on what answered; the target_id names the family; a CH32V00x's option bytes say whether NRST exists (read only, never written); then resume.
- reset: attach under reset through each candidate - the real line stops the hart at the reset vector.
- labels: the connection profile the host keeps (wire, pins, line settings: the probe keeps no wiring) and the
labels
nrst/power_hi(<name>.nrst/<name>.power_hiwith--name, a probe serving several targets), checked withconfig.find_lineagainst the probe's settings (a note when another channel already carries the name: the line would not be found);--savewrites the labels (set + save). Nothing is written without--save.
Safety: a driven or active channel is never driven, scanned or held; the power channel is touched only with --power;
holding low is open drain only. A new gpio plan lets every pin of the old one go first - the power channel too - so with
--power each new plan is followed by a clean power cycle (power_cycles in the report).
ESP32-P4 with a CH32V003 (power on GPIO5), 2026-10-02 - a run from before the 2026-10-07 change, which also read under both pulls; its "pulled-up" row (channel 4, the reset line's weak pull-up) is left out here - under pull-up and pull-down alone that channel reads floating:
$ oep pins /run/board-identify/by-id/esp32-series-30eda0ea068b --power 5 --wire swio
power: channel 5 low 300 ms (target off: read), then high 400 ms before the reads
classify: 52 channels, 16 reads each under pull-up, pull-down
floating 0-3,6,9-20,26-34,36-50,52-54
driven-high 7-8,22-23,35 (never scanned or held)
driven-low 51 (never scanned or held)
active 21 (21: 8 changes under pull-up)
follow power 4,6,9-11,13,15-16,19-23,32-33 (read otherwise with the target off: wired to it; candidates only)
reset line (hold low): 45 candidates, each held low (open drain) up to 390 ms while watching 21 (105 changes in 1.0 s running, longest lull 130 ms)
hold 4 low: 21 stopped
45 held in 3.4 s: stopped by 4
scan swio: 45 channels (0-4,6,9-20,26-34,36-50,52-54; not 7-8,21-23,35,51: driven / active) in 0.14 s -> 19
attach swio 19: target_id 00310510 (WCH DMI 0x7F) -> ch32v00x, halted at dpc 0x108
option bytes (read only): RST_MODE 10 (USER 0xf7): NRST on PD7, 12 ms ignore window
attach under reset through 4 (held 20 ms): dpc 0x0 -> the reset line
profile: wire swio, pins 19 (the host's to keep: the probe keeps no wiring)
label: oep config label ... 4 nrst (not written)
label: oep config label ... 5 power_hi (not written)
idle: oep config idle ... 5 output-high (keeps the target powered while no plan holds channel 5; not written by this tool)
released every plan (channel 5 is back to its idle state: the target is powered only while something drives it)
done in 7.3 s
19 is SWIO and 4 NRST (holding it stopped 21, and an attach under reset through it stopped at dpc 0). 22 / 23 are the target's UART (idle high: driven, so neither scanned nor held); 21 is an output its app toggles.
A faster UART bridge (port_speed, opt-in)
A probe whose optional oep.probe.link offers port_speed in its ops (oep-if-link §3; the classic ESP32 reference firmware
does) lets the host
run its UART bridge faster than the boot speed (115200) for one session. Nothing changes unless the host asks:
hst = link.open_host("/dev/ttyUSB0", port_speed=True) # 500000; takes the lock, keeps the session open
print(hst.link.speed.to_text()) # every candidate tried, the baseline, the flows, the one in force
# the full form (a baseline and the flows measured), in a session already taken:
report = link.raise_speed(hst, [500000], verify=True, flows=[("out", 2)], record=True)
# a faster rate only because the user named it (e.g. `oep speed <probe> 921600,500000`): its 1 s verify first
hst = link.open_host("/dev/ttyUSB0", port_speed=[921600, 500000])
The default ceiling is 500000 (this client's choice: oep-if-link §3 leaves the rates, how they are checked and when
to go back to the host): the default candidate is 500000 alone
(link.DEFAULT_CANDIDATES), and a host does not try faster unless the user explicitly chose it - pass a rate above
link.DEFAULT_CEILING in the candidates only when the user named it (oep speed <probe> 921600,500000, a setting). Such a
rate, in the minimal and the full form alike, is committed only after its 1 s verify: in the try
state, full frames of oep.probe.link source (in, max_frame - 7) and of sink (out, max_frame - 12), and of duplex too when
the full form verifies it, each for at least 1 s (link.FAST_VERIFY_S) at its in-flight n, judged as a flow is (below),
with no second run at n = 1; its try asks verify_ms 4000 (6000 with duplex) so it needs a lease of 5000 (7000) ms or more -
link.lease_for(candidates, flows, verify); open_host and oep speed take at least that, and a shorter lease skips
the candidate. Committed, it has the probation and in-use judging of any rate. Why: on one bridge, 921600 passed
a verify of 16 full frames each way and still broke 1-4 answers in every 9 KiB upload; 500000 was clean on every bridge
measured.
The procedure is this client's; oep-if-link §3 is the handshake alone - port_speed's request is baud, step (try / commit
/ revert) and verify_ms, it applies to the UART bridge the request comes in on and only above the boot speed, and
port_state (lock-free) reads that port's baud and phase (0 boot speed, 1 trying, 2 committed, 3 a return waiting for a
frame boundary). The minimal form (the default, about
50 ms, no measurement): each candidate in order - try (answered at the speed now, then the probe switches) -> the host
switches to the requested baud -> 20 ms -> a confirm (100 ms, up to 3) -> commit. The full form (verify=True,
or flows= given): a baseline at the boot speed per flow (this session's frames, or 60 measured), then for each
candidate every flow the session will use - flows of ("in"|"out"|"duplex", n) (in = oep.probe.link source probe -> host, out =
oep.probe.link sink host -> probe, duplex = both interleaved; n in flight, 0 = the most the link keeps) - 16 full frames
(source max_frame - 7, sink max_frame - 12), counting broken and lost and measuring KB/s; a flow fails on broken + lost >= 3 over max(2 x baseline,
5 %), runs once more at n = 1 first (then n = 1 is the link's cap), and one failed flow fails the candidate. A failed
candidate reverts (step 2) and goes back to the boot speed, confirmed there and checked with a new port_state (phase 0,
link.port_state). The first candidate that passes is kept; a
rate the probe's UART cannot make (within port_speed_tolerance_pct, 2 %) is refused and skipped. Which rates pass depends on the bridge chip and its driver (some make
only integer divisors of their clock; some break long bursts above 500000 though short checks pass), so the host chooses
them within the ceiling. The report (link.speed: base, rate, chosen, baseline, trials of SpeedTrial with
flows, in_kb_s / out_kb_s / duplex_kb_s) is there to budget a capture or a write.
The probe goes back to the boot speed by itself only when a try outlives its verify_ms, after commit when no good frame
came for port_speed_idle_ms (3 s, link.IDLE_MS; a host that died leaves the rate no longer than that), and when the
session ends (end, a lapse, a force) - a broken frame alone does not send it back. While raised the link sends a
keepalive before a request when it has been quiet for 1 s (link.KEEPALIVE_S), and hst.link.keep_alive() does the
same for a caller that sits idle for long; opening a serial port retries its first confirm for port_speed_idle_ms +
host_wait_add_ms (4 s, link.OPEN_RETRY_S) to wait out a rate left over. The link follows an end, a revert or a
restart at once, and so does a forced open of another session. A request that goes unanswered at a raised rate is sent
again at that rate first: its first wait is at most a third of port_speed_idle_ms (link.RAISED_FIRST_WAIT_S) and a quarter of the lease,
never under core §4.4's floor, so the resend still reaches a probe at the rate; only when that resend gets no answer
either does the link go back to the boot speed, confirmed, step down and send the request once more there - it never
wedges. In use, a committed rate's first 32 KiB and 1 s (probation_bytes, probation_s) are its probation: 3 or
more broken or lost frames over max(2 x baseline, 5 %), or a missed answer, there count as a verify failure and step
down at once (the 16-frame verify stays the quick gate). After it the link judges the frames of the last 3 s (none
under 50): over max(2 x baseline, 10 %) broken or lost steps the link down. A step down is port_speed revert at the
raised rate, the boot speed, a confirm, then the next lower candidate of that call that has not failed in the session
(a fresh try -> confirm -> verify -> commit; none left: the boot speed); a rate that broke is not used again in that
session, nor any rate above it (speed.stepped_down, speed.down_why, speed.step_downs with to and
probation). max_tries= bounds the candidates one call tries (a capture host wants 2). record=True (a bool, a
path, or a speed_record.SpeedRecord; the oep speed CLI's default, off in the library) keeps passed / failed rates
per (port, unit_id) under ~/.cache/oep-client/link-speed.json - a pass for 30 days, a failure for 1 day, a failure
measured within 2 s (settle_s) of a breakdown at another rate as unknown - putting a passed rate first and leaving
failed ones out; when every candidate is marked failed the slowest is tried once (speed.retried). An x- unit_id (a probe with neither a unique number nor storage, core §7.5) keys nothing: no record is kept or read
for it. The port raised is the one this host came in on - the transport TLV of confirm's answer (core §7.1) - when that
is a UART bridge; a port_speed request that comes in on no UART bridge, or a try while a port is raised, is refused unavailable (cause
6). A probe without oep.probe.link, or whose oep.probe.link does not offer port_speed, stays at its speed (the
report says why); the link test (linktest, core.link_speed) needs oep.probe.link too. Behind a broker (TCP) the broker does
this, not the client. Serial ports are also opened in the driver's low-latency mode where it has one (an FTDI's latency
timer 16 -> 1 ms tripled a UART bridge's throughput). open_host(..., baud=) names the boot speed when the board's
profile is not 115200.
The virtual bench (a working spec)
The virtual bench is a probe, the targets behind it and the fixture wiring, modelled in software after the real jigs: the environment host software is tested in, without hardware. ("Bench" alone in this repository means the real hardware-in-the-loop jigs; the software one is always "virtual bench".)
Its probe, endpoint.Endpoint, answers as oep-spec 67876b7 says (every answer carries the length of its data or list,
TLVs may follow anything, describe = declarations and lock-free state ops for the rest, capture generations); ch32rv,
this client and the probe firmware are checked against it (when the spec changes, this is brought in line before the
firmware). virtual_bench holds example declarations (profiles p4-x035, esp32-v003 = the classic ESP32 firmware's
frame limits - max_frame 512, riscv-dm max_length 488 -, esp32-v003-64 = the same at the smallest max_frame a probe
may declare, 64 (max_length 40, list / describe paged), p4-bench = a made-up jig with three RVSWD places and two
connections at once, rp2350-pins = a wire whose pins the host chooses, and core-v1 = the core alone at max_frame 64,
no interfaces (virtual_bench_serve only); what a probe keeps to itself and does not declare - its own channels, the
console's send queue of 256 bytes, a capture's widths, segment records and max_read, a group's budgets - is in
Offered.inner and VirtualProbe.own_channels), virtual_bench_serial the byte side of a serial port (COBS frames
only: what is no frame is dropped).
virtual_bench_capture is oep.fixture.logic (logic): one-shot, repeat (segments with the clock at the actual rate, a ring,
release) and streaming (data pushes while subscribed, each carrying its generation and segment), level / edge triggers
with a pretrigger, and events. What it captures is known: sample i is the counter i, channel k its bit k (a square wave
of period 2^(k+1) samples), in a layout of the widths the profile can make (its own, not declared: p4-x035 w 1-16 as
the P4's PARLIO, three channels in w 4; esp32-v003 w 8 as the classic ESP32's sampler, one-shot only). A capture only
listens, so it may be planned on pins other interfaces hold. p4-x035 also has oep.fixture.analog (4 channels of the
P4's ADC1 on GPIO16-23: an even channel k a square wave, an odd one a sine, of period 64 (k // 2 + 1) samples, 12-bit
values in 16-bit slots; four frontends of 950 / 1250 / 1750 / 3100 mV; a made-up two-point calibration and a Vrefint)
and oep.fixture.capture-group binding the logic and the analog: started together, the analog 5 us later (+-2 us).
Times are ns on the probe's one clock.
oep.fixture.i2c-target and oep.fixture.spi-target (p4-x035, esp32-v003) take their roles from the plan (SDA / SCL;
SCK / MOSI / MISO / CS - on esp32-v003 one of two fixed channel_groups exactly) and answer every op of fixture §3 / §4.
There is no bus controller: nothing is received (and an spi arm stays armed) until a test calls a hook on the endpoint
(i2c_write / i2c_read / spi_transfer: one transaction on the bus).
What it follows, in short. Core: fn 0 has no name, is never listed and has the core's mandatory ops alone; list takes
first alone; refusals go by the header (corr 0 malformed, unknown_function, unknown_operation, session_required), the
resend (core §5.1: the last corr of the last session opened, and a bounded record of whole requests and answers - a
remembered corr with other bytes is malformed, one not remembered is new when 1 to 32767 ahead, else result_lost), the
session (no_session, locked + remaining ms + owner), then every check runs before anything changes and any one reason
that applies answers; an unknown non-critical request TLV is ignored, an unknown critical one refused unsupported with
its tag as received, a TLV it implements checked the same with or without bit 7, TLV number 0 malformed; unavailable
carries cause, channel and fn; connections and streams share one u32 number space from 1, never re-used within a
boot_id; notifications (core §10) go to the connection the fn's subscribe came on, events at once, data batched by
min_bytes / max_delay_ms, and a subscription replaced or ended drops what it had not sent; the clock is ns since boot
(now_ns=; virtual_bench_serve uses time.monotonic_ns and draws its boot_id), from 0 at reboot(). Debug: attach
names method, pins and max_speed in its fixed part and always makes a new connection (busy pins unavailable cause 1, past
max_connections cause 2), method 1 only with features attach_halt; attach, scan and riscv-dm reset answer within
max_op_ms (10000; a target that restarts by itself after a reset - VirtualTarget.restart_ms - is waited for, logged in
settle_log); reset answers status, flags and pc; run with timeout_ms 0 halts at once, a failed preparation
(VirtualTarget.fail_regs) or a running hart answers stopped 3 not_run; a target op whose exchanges go unanswered fails
with status line and leaves the lines undriven (pin_state says wire-free); a high-level op whose cleanup fails
(VirtualTarget.restore_fails, step_stuck) answers, then closes the connection and its streams; found = DMSTATUS.version
2 or more and not 15 (VirtualTarget.version); halt_stuck makes a halt time out. Console: one session-owned stream a
connection; marks reset 0x40 (the reset op's ndmreset) and restart 0x41 besides the common ones; a DMDATA / dmseq
stream's send queue is handed to the target 3 / 2 bytes a poll, one poll per ms while the hart runs and none while it is
halted or a riscv-dm request of that connection runs (console_take(sid) hands all of it). Fixture: gpio modes 0-6 (the
lines outside through gpio_world); uart receives nothing until configured, status baud and format; i2c-target one form
(a write with data one frame, cut at max_length; reads from preload_tx slots, else 0xFF); spi-target without reset.
Capture: configure's TLVs follow core §2.3 alone; a bound track's start / configure is unavailable cause 4, a bind short
of resources cause 2 with the fn; a read at or past the write position answers write_pos and len 0. probe.config: label / idle /
disable items (idle 3 bytes; an item of another length malformed); the generation counts the changes of a boot from 1;
state is storage_state, saved_generation, reason; at boot the saved items are checked one by one, disable first, and what
fails is left out (partial). port_speed: baud / step / verify_ms on the UART bridge the request came in on, within
port_speed_tolerance_pct, phases 0-3 read by port_state; the virtual bench goes back after verify_ms, after
port_speed_idle_ms with no good frame, or at the session's end - never on broken frames.
Test hooks besides: what the probe does to a pin is pin_state(ch) (MISO driven only while CS is active - spi_select
-, an i2c-target open-drain with its own pull-ups - virtual_bench.with_i2c_pullups -, a plan changing no pin until use,
a closed connection's pins back to idle); an idle pull a channel lacks (no_pull);
virtual_bench.with_unit_id(probe, "x-...") makes a probe with an x- unit_id; virtual_bench.without(probe, name)
leaves an interface out (virtual_bench.without(p, virtual_bench.RESTART)); every fn's describe carries the ops tag
(VirtualProbe fills in every op of the interface's table when a profile gives none, virtual_bench.ops_of(name, *without) leaves optional ones out, a profile's ops tag outside core §7.4 - a test's, fill_ops=False - is served as
given); every profile but core-v1 lists oep.probe.plan (plan_roles 32) and oep.probe.restart (restart_max_ms 2000)
after the fns it had: p4-x035 plan 14 / restart 15, esp32-v003 11 / 12, p4-bench 8 / 9, rp2350-pins 7 / 8.
tests/test_spec_vectors.py runs oep-spec's vectors (sessions.json step by step, the ops cases byte for byte) on the
virtual bench and through this client.
Other programs' tests run virtual_bench_serve as a child process:
python -m oep_client.virtual_bench_serve --pty --profile p4-bench --console 'uptime %d\r\n' --every 100
# first line: PTY /dev/pts/N (PORT n with --tcp 0); it ends when stdin closes (not with --keep-on-eof)
A line reboot on its stdin reboots the probe mid-session (Endpoint.reboot) with a new random boot_id: the session
table, the resend table, connections, streams, subscriptions, the plan and the unsaved settings go, the saved
settings (--label) apply again, the clock starts from 0, a serial port is back at its boot speed, and a request with
the old session gets no_session; confirm and open show the new boot_id. The pty or TCP connection stays open. stderr says
virtual_bench_serve: rebooted, boot_id 0x......... lose [CONNECTION] loses that connection's line for good (every live connection without one; Endpoint.lose, debug §2): it and its console streams close, and a request naming either is no_resource; stderr says virtual_bench_serve: lost connection(s) .... capture-overflow FN [ERROR] makes capture FN drop data inside the segment it is taking (Endpoint.capture_overflow): that segment is not handed out and the track stops in error (state 6, error ERROR, default 2). Any other line is ignored with a message on stderr. oep.probe.restart's
restart (oep-if-restart; every profile but core-v1 lists the interface with restart_max_ms 2000 in its describe; --no-restart
makes a probe without it - not listed, its fn unknown_function) does the same from a request: the answer goes out
first, then the probe reboots, and what it had read behind the request is dropped:
# the test holds the child (stdin=PIPE) and writes the line
proc.stdin.write(b"reboot\n"); proc.stdin.flush()
The pty is a serial port (the host opens it with TIOCEXCL); --tcp PORT speaks length frames by default, as a
probe's TCP transport does (--framing length: the listener is a TCP transport of the probe, listed in describe and
named by every confirm; a length over max_frame closes the connection); --framing cobs, only when asked for, emulates
a serial port over the socket (COBS frames only, one connection at a time). With length framing TCP serves up to --tcp-connections N
connections at once (default 3, as the reference probe; one more is accepted and closed at once), each a transport of its
own (transports §1: its confirm and revision in use, notifications on the connection their fn's subscribe came on) and
all on one probe: an open from one connection while another's session holds the lock is refused locked, and lock_state
shows it from each. --framing cobs (a serial port) still serves one connection at a time. A closed connection does not
end its session (transports §3): the session, lock, subscriptions and resend table stay until the lease runs out,
notifications for it are dropped, and an open with the same id from another connection takes the session back (core
§6.2), its notifications with it. --once ends the program when no connection is left after one was served (with
length framing: when the last of the overlapping connections closes). Faults: --drop N (the N-th answer is not sent, once; the request did run,
so a resend gets the remembered result), --noise TEXT (noise before every answer), --corrupt N (the N-th answer's CRC
broken once). --uart-rx gives the first fixture UART RX bytes once a session planned and configured it, --run-hook a host's own model of
riscv-dm run, --capture-slipped flags bit2 on every capture segment.
--silent-until-reset N makes the N-th pair's target answer nothing until a reset through its line (a host's attach with
the reset TLV; --label CH=TEXT, e.g. 23=v003.nrst, names the line of the host's profile v003, host guide §5.3). port_speed: esp32-v003's oep.probe.link offers it
(--no-port-speed takes it out of the ops), and --broken-rate RATE[:MIN_SIZE][:in|out] makes a rate break frames (in process,
virtual_bench_serial.VirtualSerialStream also garbles everything while the host's own rate differs from the probe's). Events and data pushes go out on the pty and on TCP
(both framings). The rest: --help.
Announcing itself (DNS-SD, a CI on one host). --announce (with --tcp; length framing) answers mDNS / DNS-SD
queries for _oep._tcp as a probe listening on TCP does (transports §3): PTR _oep._tcp.local. -> instance
OEP virtual <unit_id> <port>, its SRV (the port, host oep-virtual-<unit_id>-<port>.local.), its TXT
unit_id=<unit_id> (fn 0's describe's; --unit-id ID sets it) and the host's A record: the --listen address
(127.0.0.1 by default; --listen 0.0.0.0: every announcing interface's address, loopback last). python-zeroconf answers
when the mdns extra is installed, else a minimal responder of this package (virtual_bench_mdns; IPv4): a query from
a port other than 5353 (legacy unicast, RFC 6762 §6.7) is answered to its sender's address and port with its ID and
question and TTL 10; a query from 5353 is answered on the group. --announce-on ADDR (repeatable) picks the interfaces
by IPv4 address (default: every one, loopback included where the OS lets it join the group - Linux does);
--announce-engine auto|zeroconf|minimal. A CI on one machine runs the querier and the responder on the same host:
python -m oep_client.virtual_bench_serve --tcp 0 --announce --profile esp32-v003 --unit-id 0123456789ab
# stdout: PORT n (the announcement is up by then); stderr: virtual_bench_serve: announcing ...
# then: a one-shot PTR query for _oep._tcp.local. from an ephemeral port to 224.0.0.251:5353 (IPv4) - the answer comes
# back to that port: PTR, SRV (port n), TXT unit_id=0123456789ab, A 127.0.0.1 - and open tcp://127.0.0.1:n
The query's multicast loops back to this host on the interface it left by, and the responder has joined the group on
every interface. A host with no multicast route needs --announce-on 127.0.0.1 and the query sent with
IP_MULTICAST_IF 127.0.0.1. A different --unit-id per job keeps parallel jobs apart (the port is in the instance
name too). A container or VM answers only on its own network. tests/test_virtual_bench_announce.py does this with
oep find, discovery.find_unit and tcp:UNIT_ID (both engines) - with in-process responders announcing a non-OEP
TCP service and a wrong TXT unit_id beside it, which the verify drops - skipped where multicast does not loop back.
The v0 client (oep_client.v0) was removed on 2026-09-26 (it stays in the git history); no probe speaks v0 any more.
Metadata
Release files for oep-client-python 0.0.34
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| oep_client_python-0.0.34.tar.gz | 2.1 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| oep_client_python-0.0.34-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 3.0 MB
Release files / oep_client_python-0.0.34.tar.gz
| Download URL | oep_client_python-0.0.34.tar.gz |
|---|---|
| Size | 2.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4cdb094a9da73db4f323f452315a931a13826867f808233ca252bf0951f33633
|
|
BLAKE2b-256 checksum How to use checksums |
38bde97cc5d939aa677710a0f7925790dda263feb688a1eca6876b3099fddf52
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / oep_client_python-0.0.34-py3-none-any.whl
| Download URL | oep_client_python-0.0.34-py3-none-any.whl |
|---|---|
| Size | 843.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cab7959d155a24ab535afdb101cf5a28ff5f2779dc775be580650fdb1f25b077
|
|
BLAKE2b-256 checksum How to use checksums |
c8ef7189ec77c1d9b956d18efcbf2905a5e0d0e4d9341113f7b2b18399109b6f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log