Metadata-Version: 2.5
Name: oep-client-python
Version: 0.0.34
Summary: Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a virtual bench
Project-URL: Homepage, https://github.com/Open-Embedded-Probe/oep-client-python
Project-URL: Repository, https://github.com/Open-Embedded-Probe/oep-client-python
Author: TANAKA Masayuki
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Requires-Dist: pyserial>=3.5
Requires-Dist: pyusb>=1.3
Requires-Dist: tomli>=2; python_version < '3.11'
Provides-Extra: hid
Requires-Dist: hidapi>=0.14; extra == 'hid'
Provides-Extra: mdns
Requires-Dist: zeroconf>=0.131; extra == 'mdns'
Provides-Extra: usb-async
Requires-Dist: libusb1>=3; extra == 'usb-async'
Description-Content-Type: text/markdown

# OEP Python client

[日本語](README.ja.md)

The host side of Open Embedded Probe (OEP). It speaks the v1 protocol of
[oep-spec](https://github.com/Open-Embedded-Probe/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](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](https://github.com/Open-Embedded-Probe/oep-spec/blob/main/README.ja.md) and
[review guide](https://github.com/Open-Embedded-Probe/oep-spec/blob/main/docs/review-guide.ja.md) (what is where); [getting started](https://github.com/Open-Embedded-Probe/oep-spec/blob/main/docs/getting-started.ja.md)
builds the smallest probe and host, [docs/oep-core.ja.md](https://github.com/Open-Embedded-Probe/oep-spec/blob/main/docs/oep-core.ja.md) is the protocol core, and
[docs/conformance.ja.md](https://github.com/Open-Embedded-Probe/oep-spec/blob/main/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.

```sh
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](docs/hardware-quickstart.ja.md). See the shared [testing policy](docs/testing-policy.ja.md) and [guide for projects using OEP](docs/testing-consumers.ja.md). The [hardware design](docs/hardware-configuration.ja.md) and [configuration examples](docs/hardware-schema.ja.md) 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

```python
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`](https://github.com/Open-Embedded-Probe/oep-client-python/blob/main/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:

```sh
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

```sh
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 after `end`), 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.take` waits or names the holder). An `x-` 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=False` reads 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 find` browses `_oep._tcp` (transports §3, host guide §4.1) and lists unit_id, host, port and address. The
  service name `oep` is 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 (an `OEP!` 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-verify` lists every instance as announced.
  `discovery.find_unit` (and so `tcp:UNIT_ID`) verifies the same way and passes over an instance it cannot verify;
  `open_host` then 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 as `tcp://HOST:PORT`.
- `tcp:UNIT_ID` opens 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 linktest` waits 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).

1. **classify**: every channel `oep.fixture.gpio` allows 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 CH` the target is first read
   switched off: the channels that read otherwise follow its power. These are candidates only.
2. **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.
3. **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`.
4. **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.
5. **reset**: attach under reset through each candidate - the real line stops the hart at the reset vector.
6. **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_hi` with `--name`, a probe serving several targets),
   checked with `config.find_line` against the probe's settings (a note when another channel already carries the name:
   the line would not be found); `--save` writes 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:

```python
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:

```sh
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:

```python
# 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:

```sh
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.
