Skip to main content

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 c6ab5d9 (no v0.x tag yet; oep-spec versioning §6 - before the freeze, revision 1 alone does not fix the forms, so an implementation names the spec it implements). That is the logic capture changes of 2026-10-07 (66c49e7 .. dd5a886: the layout's w is any integer 1-128, a segment not kept seamless is not handed out and stops the track in error, and multirate - per-channel reduction by sample / any_active / edge_latch in blocks of L base samples, capture.Multirate / LogicCapture.decode_multirate, served by the virtual bench), the interface re-check of 0991759 (0098b56 .. 2c6d18d: marks and capture segments page by serial from from_serial inclusive, console streams' first is u16, capture's configure contract, generations that wrap past 0xFFFFFFFF to 1 and ride on every event, the capture-group start answer's fixed part, step / run / transfer details, spi-target bit packing), the wifi item of probe.config and TCP discovery (c2b8007, 62c1988: below, "Wi-Fi and TCP"), the external review re-check of 2026-10-07 (af3d52b .. 283e5b5: a frame may be split over writes but no sender pauses probe_frame_gap_ms inside one off TCP; a closed transport does not end a session; describe TLVs and max_length fit the smallest max_frame; channels from a probe with channels, 0 .. channels - 1), the fixes after it (3759027 .. f8bb2de: no resend_max - the host decides its resends; interface names 1 to 48 bytes; i2c-target's errors at most 1 a write; the host guide's sink count max_frame - 12, as this client already sends; a restarting host keeps its session id, below), the re-re-check of 764b110 (29902a6 .. 9118dc0: a probe with the wifi item answers max_frame 112 or more on every transport, wifi_min_max_frame; a TCP probe may advertise itself as _oep._tcp or not) and the rule review of 2026-10-07 (7688c49 .. 0f455a0, docs/v1-rule-review-2026-10-07.ja.md §2 / §7): no ignored TLV (an unknown non-critical request TLV is ignored silently, one the probe implements is checked the same with or without bit 7), one refusal order - the header, the resend table, the session, then any one reason that applies -, a resend table of corr and answer (no corr_reused), list by first alone, a smaller fn 0 describe, unavailable's payload cause / channel / fn, attach, scan and riscv-dm reset bounded by max_op_ms, reset without a method, DATA0 / DATA1 restore in riscv-dm, a u8 drive level, one i2c-target form, no console send_queue, capture's configure under core §2.3 alone, probe.config slots without lock / boot_reset, a bind of one stream and the probe's own hash, and port_speed as the handshake baud / step / verify_ms. And before it the 2026-10-06 simplification (one 10-byte request header, TLV len u16, closed fixed forms, the ops describe tag, no resume) and structure (289bde0 .. 498ae95: the core is fn 0 with no name; oep.probe.plan, oep.probe.restart, oep.probe.link found by name; subscribe / unsubscribe are ops 0x30 / 0x32 of the interface that sends).

Until the freeze the Japanese text (.ja.md) is the specification's working text; the English documents are regenerated from it at the freeze and become authoritative then. Start with oep-spec's README and review guide (what is where); getting started builds the smallest probe and host, docs/oep-core.md is the protocol core, and docs/conformance.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_PROBE_DIR=<oep-probe-arduino checkout> 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_vectors.py against this client and the virtual bench) are copied from oep-spec with tools/sync_registry.sh. 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).

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. A probe firmware and this client go together by version: OpenEmbeddedProbe X.Y.Z with oep-client-python X.Y.Z (until the v1 freeze every release may break the wire; the versions move together).

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), 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; 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.7, 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 0x30 / 0x32 (core §11.3; min_bytes / max_delay_ms batch the data only, events come at once; an fn that sends nothing answers unknown_operation). 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 until the probe's restart_max_ms (oep.probe.restart's describe, read before the restart; core.restart_max_ms) or 10 s when it declares none (a longer wait_s is cut to restart_max_ms: host guide §5.2 retries only that long) - 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 (argument time + 1000 ms + a serial port's transfer time with min_max_frame until a confirm answer, wait_floor_s); 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, attach - max_speed always sent, reset=(channel, hold_ms) for an attach under reset -, detach, connections), oep.target.riscv-dm (answers count their values; reset(confirm=True) -> (flags, pc) - flags bit0 reached, bit1 verified -, 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), Wire.search_retries (the extra attempts of the attach's bring-up, sent only when one ran), 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 a slot (PinFinder)
console oep.target.console (position streams: read answers carry their length, marks are time_ns, the lock-free streams() list; 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; SDI takes nothing) 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 (an output's strength per element as a u8 level of the probe's drive_levels: set([(ch, mode, Drive.level(n))]), drive_levels().at_most(10) = the strongest level of about 10 mA or less, Drive.default() = 0xFF; a level past drive_levels, or any drive on a probe without them, is refused unsupported; set returns None, read the levels) / uart (its stream is the plan's; status() = UartStatus(baud, format) in force) / 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 (slots - no lock, no boot_reset; the host checks the target with connections' tid -, a bind of one stream - Bind(port, stream=("slot", n) | ("uart", fn)) -, plan / label / idle - 4 bytes, its drive level or the default 0xFF - / uart items, get / set / unset / save / erase; describe() = the declarations, state() = the live storage / slot (SlotState(slot, state, connection, last_try_at_ns), connected / absent) / bind (BindState(port, flow)) state, the storage as the last page says; the hash is the probe's own, never computed here: 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, slot, "nrst") = probe.config §1.3's line lookup, the firmware's fixed labels as its step (c)); the wifi item (Wifi(index, ssid, passphrase), the passphrase write-only: get gives KEEP, sent back it keeps the entry's; repr / shown() say set / none; Declared.wifi_max, State.wifi = WifiState(state, entry, reason, rssi, ipv4); wifi_from_env(); below)
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 (this host sends mode, rate, trigger, pretrigger and frontend critical, its own choice); the answer (no timing, no rate_accuracy) holds the actual values, its samples among them; a start's blocking_ms is waited out with nothing sent (then a resync on length frames). Every start is a generation (LogicCapture.generation) that read and release name - read_segment(segment) does it by itself; status() returns a Status. 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(confirm=True)
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 live state
oep config state <probe>                     # the live slot / bind / storage state alone (lock-free, for polling)
oep config slot <probe> --name x035 --wire rvswd --pins 2,54 --attach at-boot --retry 1 --mechanism dmseq
oep config bind <probe> --port 1 --stream slot:x035   # the one stream a serial port carries
oep config uart <probe> oep.fixture.uart 115200 --format 8N1   # applied whenever that UART's plan has RX or TX
oep config save <probe>                      # kept over a restart (also: remove = unset, erase)
oep speed <probe> [921600,500000]            # 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, a slot (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 config wifi <probe> --index 0 --ssid LAB --pass-prompt [--save]   # a Wi-Fi network (below); --pass-env VAR, --open
oep config wifi <probe> --from-env [--save]  # OEP_WIFI_SSID_<n> / OEP_WIFI_PASS_<n>
oep config wifi-unset <probe> --index 0 [--save]
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 (taken as a resend of its open, core §6.2: nothing released) and ends it at once - before opening its own. 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 as 8 hex digits; 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.

Wi-Fi and TCP

A probe whose describe lists the wifi item (probe.config §1.4, item 0x08; describe's wifi_max) joins the networks of its settings, tried in index order, and serves OEP over TCP. The passphrase is write-only: get answers pass_len 0xFF (set) or 0 (none) and nothing after it, and a set with 0xFF keeps that index's passphrase - so get's items sent back change nothing.

  • oep config wifi takes the passphrase from a prompt without echo (--pass-prompt) or an environment variable (--pass-env VAR), never from the command line (host guide §15.1); --open is a network without one; with neither the entry keeps its passphrase (pass_len 0xFF: an SSID change alone) - an entry that does not exist yet needs one of them. Nothing prints a passphrase: show / state (text and --json) say passphrase set / none, Wifi's repr too, and a too-short one's error names its length only.
  • --from-env reads OEP_WIFI_SSID_<n> / OEP_WIFI_PASS_<n> (n = the index, below wifi_max; no PASS: open) and sends the entries whose SSID or passphrase presence differ from the probe's (a passphrase cannot be compared; --force sends every entry): for a bench's set-up script. config.same_items / apply compare a wifi item the same way.
  • show and state print the link from state's wifi TLV (probe.config §3.3): connected, entry 0, rssi -55 dBm, ip 192.168.1.23, connecting, entry 0, waiting, reason auth (not-found, auth, no-address, other) or off.
  • Setting it over the TCP link it changes drops that link after the answer (§1.4): set it over a serial port or USB.
  • 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 (its address is in oep config state over the serial 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 and its reasons are in oep-spec's host development guide (§19 "Finding pins"); 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), which oep-spec docs/target-scan-notes.ja.md records the sources of.

  1. classify: every channel oep.fixture.gpio allows is read 16 times under pull-up, then pull-down (host guide §19.1): 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. slot: a suggested oep config slot line and the labels <slot>.nrst / <slot>.power_hi, 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 them (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
slot: oep config slot ... --name ch32v00x --wire swio --pins 19   (not written; --save writes it)
label: oep config label ... 4 ch32v00x.nrst   (not written)
label: oep config label ... 5 ch32v00x.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

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 (the advice of the oep-spec host guide §17): 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 (host guide §17.3.3): 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 (oep-spec host guide §17.5).

The host's procedure is the oep-spec host guide §17; oep-if-link §3 is the handshake alone - port_speed's request is baud, step and verify_ms, and it applies to the UART bridge the request comes in on. 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. 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. A request that goes unanswered at a raised rate is sent again at that rate first (host guide §17.3.2 item 4): 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, answers not supported and stays at its speed; 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 says (every answer carries the length of its data or list, TLVs may follow anything, one resource number space, describe = declarations and state 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, no wifi item: that needs max_frame 112), p4-bench = a made-up jig with three slots and two seats, rp2350-pins = a wire whose pins the host chooses; 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 candidates, raw bytes and a bind whose position stays during a session and carries on after it).

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), 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; ESP32-style frontends; 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), the trigger of one marked on both. 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).

The rule review of 2026-10-07 (oep-spec 7688c49 .. 0f455a0, docs/v1-rule-review-2026-10-07.ja.md §2 / §7) is in. Core: an unknown non-critical request TLV is ignored silently and an unknown critical one refused unsupported with its tag as received; a TLV the virtual bench implements is checked the same with or without bit 7 (another length, longer too, or an excluded value: malformed; a value left unused or not handled: unsupported with the tag as received); a non-repeating tag twice: the first is used; booleans are non-zero = true; request text is not checked (owner keeps 1-32 bytes). It refuses by the header (unknown_function, unknown_operation, session_required), the resend table (corr -> answer, no corr_reused), the session (no_session, locked), then checks everything before any change and answers any one reason that applies. list takes first alone; fn 0's describe has no implementation, reserved, profile, resets_on_open or discoverable; unavailable carries cause, channel and fn; resource numbers go +1, skipping those in use; oep.probe.link source answers at most max_frame - 7. Debug: attach, scan and riscv-dm reset answer within max_op_ms (10000, the wait for a silent DM included, 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; the target_id scheme is dmi_7f; a count > 0 scan does not look at skip. The console's send queue is the probe's own (no send_queue in describe), and its reads stop while a riscv-dm request of that connection runs and while the hart is halted. Capture: configure's TLVs follow core §2.3 alone, its answer has no timing / rate_accuracy; describe's mode is mode max_samples max_segments, channels is max alone, capture-group declares its tracks alone; a bound track's start / configure is unavailable cause 4, a bind short of resources cause 2 with the fn. Fixture: gpio modes 0-6, drive a u8 level (0xFF the default; a level past drive_levels, or any drive without them, unsupported); uart 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; no arm_rx, reset or modes); spi-target without reset. probe.config: idle 4 bytes; an item of another length malformed; slots without lock / boot_reset, slot_state connected / absent; a bind of one stream; the hash is the virtual bench's own (do not compute it); storage_hash is get's hash when the saved settings became current; disable and idle go first at boot. port_speed is baud / step / verify_ms on the UART bridge the request came in on, within port_speed_tolerance_pct; 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.

The 2026-10-07 review replaced several of the earlier rules below (the ignored TLV, corr_reused, the refusal order, the debug budgets and reset_settle_ms, the slot lock / boot_reset and the bind modes, the drive kinds, the i2c-target modes, the console's send_queue, ...); they stay here as history:

  • 2026-10-02 (docs/v1-rule-change-proposal-2026-10-02.md): confirm names its transport and refuses an unknown revision with the range it handles; rejected answers are remembered; lease 1000-60000; found = DMSTATUS.version >= 2 and not 15 (VirtualTarget.version); halt / step failures (halt_stuck, step_stuck); the line search's step (c) over the firmware's labels; an idle pull a channel lacks (no_pull). 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). virtual_bench.with_unit_id(probe, "x-...") makes a probe with an x- unit_id.
  • 2026-10-06 rules (2e70f40 .. 40291a4): an op the interface does not define, or an optional op the probe does not declare, is unknown_operation (offers); the clock is ns since boot (now_ns=; virtual_bench_serve uses time.monotonic_ns and draws its boot_id), never back, from 0 at reboot(); confirm's bounds and max_op_ms are checked when an Endpoint is built; a riscv-dm op whose target does not answer fails with status line and leaves the lines undriven (pin_state says wire-free); esp32-v003's spi-target declares a cs_setup_ns. No profile has a pinless wire.
  • 2026-10-06 simplification (b69ec26 .. 9d4baf9) and after (f0c68bf, d34dafa, 4bd3a87, 59dd028): one 10-byte request header with session_id; TLVs as tag(u8) len(u16) value; every fn's describe carries the ops tag (virtual_bench.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); no resume; console streams stay the probe's per place and mechanism, and a stream's send queue is handed to the target 2 (dmseq) or 3 (DMDATA) bytes a poll while the hart runs (console_take(sid) hands all of it); an attach that joins a live connection keeps the settings it does not carry. tests/test_vectors.py runs sessions.json step by step and every ops.json case on the virtual bench in the state it names.
  • 2026-10-06 structure (289bde0 .. 498ae95): fn 0 is the core, never in list, its ops the eight mandatory ones. Every profile 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. virtual_bench.without(probe, name) leaves an interface out (virtual_bench.without(p, virtual_bench.RESTART)). logic, analog and capture-group set subscribe / unsubscribe (0x30 / 0x32); min_bytes / max_delay_ms hold a streaming capture's data, never an event. A profile's ops tag outside core §7.4 (a test's, fill_ops=False) is served as given.

Other programs' tests run virtual_bench_serve as a child process:

python -m oep_client.virtual_bench_serve --pty --profile p4-bench --slot x035 --bind 0 \
    --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 (--slot, --bind N - the serial port carries the N-th --slot's console -, --label, --uart-plan) apply again, 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......... wifi-air [SSID[=PASS] ...] (%-encoded words) sets the Wi-Fi networks in range (also --wifi-air SSID[=PASS], --wifi-join-ms, --wifi-ip): esp32-v003 has the wifi item (wifi_max 4), its entries are tried against these in index order, and state's wifi TLV shows connecting for --wifi-join-ms (500), then connected at --wifi-ip (127.0.0.1, where the program listens) or waiting with the reason; changing the entry in use joins again. lose [CONNECTION] loses that connection's line for good (every live connection without one; Endpoint.lose, debug §2): it closes, its console streams get mark link-lost and close with detail 4, a request naming it is no_connection, and an at-boot --slot attaches again at its next retry with its console back under the same stream number; stderr says virtual_bench_serve: lost connection(s) .... Any other line is ignored with a message on stderr. oep.probe.restart's restart (oep-if-restart; every profile 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 and the port's raw bytes, 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-plan / --uart-rx give the first fixture UART a plan and RX bytes, --run-hook a host's own model of riscv-dm run, --capture-slipped flags bit2 on every capture segment. --no-drive-levels takes the gpio's drive_levels away (a probe that cannot switch the output strength: a set with a drive is refused unsupported). --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 a slot's line, probe.config §1.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.

Metadata

Release files for oep-client-python 0.0.29

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for oep-client-python 0.0.29
File Size Uploaded
oep_client_python-0.0.29.tar.gz 730.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for oep-client-python 0.0.29
File Interpreter ABI Platform
oep_client_python-0.0.29-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / oep_client_python-0.0.29.tar.gz

Download URL oep_client_python-0.0.29.tar.gz
Size 730.5 kB
Tags Source
SHA-256 checksum
How to use checksums
9e3d2d9d8dce00a47b042fa1c711b3dcb1cb95bb11120c6180a30e210f856073
BLAKE2b-256 checksum
How to use checksums
bf9f4ca0041881063161250af80db18dd55905a90bc164a1df9aa8fdc9ff1676
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 8, 2026.

Transparency log

Release files / oep_client_python-0.0.29-py3-none-any.whl

Download URL oep_client_python-0.0.29-py3-none-any.whl
Size 331.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b0a98fe6e16b1a1bb97cadea85e4d735ee761a6b2edb075e45a3486614cdf73a
BLAKE2b-256 checksum
How to use checksums
137bbfce6b250b4a6dbf1572491dae225b31e5372f5922abd56a7709b9bd355f
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 8, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page