spvirit — EPICS PVAccess for Python, powered by Rust
spvirit is a pure-Rust implementation of the EPICS PVAccess protocol with
first-class Python bindings. It lets you build soft IOCs, simulators, gateways,
and clients entirely from Python — no EPICS base installation required — while
the protocol machinery, server, and network stack run in Rust on a Tokio
runtime.
=
=
=
# published to all monitoring clients
# read the live value
This document is the complete guide to the Python API. For the Rust crates,
protocol internals, and the CLI tools (spget, spput, spmonitor,
spserver), see the repository README.
Table of contents
- Installation
- Core concepts
- IOC-style record PVs vs raw NT PVs
- Building servers with typed PV handles
- Loading EPICS .db files
- The classic builder API
- Runtime store access
- Dynamic sources
- Normative Type classes
- Client API
- Low-level API: spvirit.lowlevel
- Wire codec: spvirit.codec
- Threading and async model
- Errors and exceptions
- Examples
- Building from source
Installation
Wheels are published for Linux (x86_64, aarch64), Windows (x86_64), and macOS (x86_64, arm64) for Python 3.9+ (one abi3 wheel per platform). No EPICS base, no compiler needed.
To build from source instead, see Building from source.
Core concepts
- PV handles (
spvirit.Pv) are typed references to process variables. You create them with factory functions (spvirit.ai(...),spvirit.ao(...), …), hand them to aServer, and thenset()/get()them freely from Python while clients read, write, and monitor them over the network. - The server owns a record store that speaks the PVAccess protocol:
search/beacon UDP, TCP circuits, monitors with
MDEL/ADELdeadbands, alarm computation, and QSRV-style field access (PV.RTYP,PV.DESC,PV.EGU, …) all work out of the box, including with the EPICS Archiver Appliance and standard tools (pvget,pvput,pvmonitor, Phoebus). - Sources let you claim PV names dynamically from Python objects instead of a static record store — for gateways, bridges, or fully virtual PVs.
- The client side offers a high-level
Client(get/put/monitor/info) and a low-levelChannel(persistent connection, raw frame access) plus discovery and codec utilities. - Everything blocking releases the GIL and runs on a shared Tokio runtime;
async variants (
set_async,get_async,connect_async, ...) integrate withasyncio. See Threading and async model.
IOC-style record PVs vs raw NT PVs
Everything on the wire is a Normative Type (usually NTScalar), but the API works at two distinct levels — know which one you are on:
IOC-style records are what the typed handles, the classic builder, and
.db loading create. The server behaves like an EPICS record: units,
prec, desc, and limits become record fields visible QSRV-style
(PV.EGU, PV.DESC, …); MDEL/ADEL deadbands throttle monitors; alarm
severity is computed from the alarm limits (or set with set_alarm); every
post is timestamped for you. You work with plain Python values —
pv.set(21.5), store.set_value(...).
Raw NT PVs are full Normative Type payloads you construct and move
yourself: NtScalar and friends written with
store.put_nt(...), published with notifier.notify(...), or served from a
dynamic source's get/put. You control — and must
supply — the alarm, timestamp, display, and control metadata on each
payload; no deadbands or alarm computation are applied for you (an explicit
timestamp is honored; a zeroed one is stamped by the server).
| IOC-style records | Raw NT payloads | |
|---|---|---|
| Created via | spvirit.ai(...) …, ServerBuilder, db_file= |
NtScalar(...) …, PvInfo + source |
| Read/write | plain values (set/get, set_value) |
whole payloads (put_nt/get_nt, notify) |
| Alarms, timestamps, deadbands | managed by the server | yours, per update |
| Best for | soft IOCs, simulators, record-like PVs | gateways, tables, images, per-update metadata |
The levels mix freely: store.get_nt(name) returns the full NT payload of
an IOC-style record, and store.put_nt writes one — useful when a mostly
record-like PV occasionally needs explicit metadata.
Building servers with typed PV handles
This is the recommended API. Each factory returns a Pv handle; the handle is
pending until a Server is built from it, and live afterwards.
Creating PVs
Scalar records (each is served as an NTScalar with the given value type):
| Factory | Python type | Wire NT type | Writable by clients | EPICS analogue |
|---|---|---|---|---|
spvirit.ai(name, initial, **opts) |
float |
NTScalar double |
no | analog input |
spvirit.ao(name, initial, **opts) |
float |
NTScalar double |
yes | analog output |
spvirit.bi(name, initial, **opts) |
bool |
NTScalar boolean |
no | binary input |
spvirit.bo(name, initial, **opts) |
bool |
NTScalar boolean |
yes | binary output |
spvirit.longin(name, initial, **opts) |
int |
NTScalar int (32-bit) |
no | long input |
spvirit.longout(name, initial, **opts) |
int |
NTScalar int (32-bit) |
yes | long output |
spvirit.string_in(name, initial, **opts) |
str |
NTScalar string |
no | string input |
spvirit.string_out(name, initial, **opts) |
str |
NTScalar string |
yes | string output |
"Read-only over the wire" means network clients cannot PUT the value; your
Python code can always set() it.
NT scalar type coverage
PVAccess defines twelve NTScalar value types: boolean, byte, short,
int, long, their unsigned variants (ubyte, ushort, uint, ulong),
float, double, and string. Handles reduce these to four Python value
kinds, and server.pv(name) — which can attach to any served record,
including .db-loaded and classic-builder ones — maps wire types onto
handles as follows:
| Wire NT scalar type | Handle kind | Notes |
|---|---|---|
double, float |
float |
float (f32) is widened to Python float |
boolean |
bool |
|
byte, short, int |
int |
narrower ints are widened to 32-bit |
string |
str |
|
| NTEnum | int |
the value is the choice index |
| NTScalarArray (any element type) | array | see element notes below |
long, ubyte, ushort, uint, ulong |
— | server.pv() raises KeyError |
64-bit and unsigned scalar records cannot currently be created from the
handle factories either — longin/longout are 32-bit. If you need those
wire types today, serve them via a dynamic source, whose
PvInfo accepts the full type-string set (e.g. "long", "ulong",
"ushort"), and read/write them through source get/put rather than a
typed handle.
Array element types: waveform/aai/aao infer the element type from the
data — bytes becomes an unsigned-byte (ubyte[]) array and reads back as
bytes; lists become boolean[]/long[]/double[]/string[] from the
first element (int lists are stored as 64-bit elements) and read back as
lists. Mixed-type lists raise TypeError.
Enum records (served as NTEnum, value is the choice index):
=
=
mbbi is read-only over the wire, mbbo is writable. Writes with an
out-of-range index are rejected. Only the desc option applies to enums.
Array records (served as NTScalarArray):
= # writable
= # read-only, U8 array
= # writable
data may be a list of bool/int/float/str (element type inferred from
the first element; an empty list defaults to double[]) or bytes (stored
as an unsigned-byte array, returned to Python as bytes).
Type-inferred creation — spvirit.pv(name, initial, **opts) picks the record
type from the initial value:
| Initial value | Result |
|---|---|
bool |
bo (checked before int — True is an int in Python) |
int |
longout |
list or bytes |
waveform |
float |
ao |
str |
string_out |
| anything else | TypeError |
Inference always produces the writable flavor; use the explicit factories when you need read-only records.
Common options
All scalar factories (and spvirit.pv) accept these keyword-only options:
=
mdelsuppresses monitor updates smaller than the deadband;adeldoes the same for the archive-tuned monitor channel (what the Archiver Appliance subscribes to).alarm_limitscombined with the server's alarm computation drives MINOR/MAJOR alarm severity automatically as the value crosses the limits.- Field values are visible to clients QSRV-style:
pvget SIM:PRESSURE.EGU,SIM:PRESSURE.DESC,SIM:PRESSURE.RTYP, etc.
Reading and writing: set / get / set_async / get_async
# blocking; full posting pipeline (monitors, deadbands, alarms)
= # blocking; returns float/bool/int/str/list per PV type
# asyncio variants
await
= await
- All four release the GIL; blocking calls are safe from any Python thread.
- Writing the wrong Python type raises
TypeError. - Calling
set/geton a handle that has not been given to aServeryet raisesRuntimeError("unbound"). pv.nameis the PV name;repr(pv)shows the name and value kind, e.g.<spvirit.Pv 'SIM:TEMP' (float)>.set()writes authoritatively: it is not subject to the PV'son_putvalidator, which guards client (wire) writes only — the same split as p4p'spost()vs its put handler.
Reacting to client writes: on_put
on_put attaches a validator/handler that runs before a client PUT is
applied. Use it as a decorator or a plain method call:
=
return False # reject: client's put fails on the wire
- The callback receives
(pv, value)— the handle itself and the incoming value, already converted to the handle's Python type. - Returning
Falseor raising any exception rejects the PUT: the value is not applied and the writing client receives an error. Any other return value (includingNone) accepts it. - Callbacks may freely call
set()/get()on other PVs (re-entrancy is safe), e.g. to update a readback when a setpoint changes. - Not supported on array PVs (
TypeError). on_putreturns the callback unchanged, so the decorated function remains usable.
Periodic updates: scan
scan registers a function called at a fixed period; its return value is
posted to the PV. Two forms:
=
# decorator form
return
=
=
# direct form: scan(period, fn)
- The callback receives the handle and must return the new value.
- If it returns
None, returns a non-convertible value, or raises, the scan re-posts the last value this scan produced (before the first successful tick, the type default:0.0,False,0, or""). It does not read the PV's live value. - Scans must be attached before constructing the
Server; attaching one to an already-bound handle is a no-op (a warning is logged). - Not supported on array PVs (
TypeError).
Computed PVs: calc
spvirit.calc creates a read-only float PV recomputed whenever any of its
input PVs changes:
=
=
=
- Inputs must all be float PVs (
ai/ao); anything else raisesTypeError. - The callback receives the current input values as
list[float]and returns afloat. Exceptions or non-float returns are logged and treated as0.0. - Like scans, calc PVs must be created before the
Serveris built.
Alarms: set_alarm
Set a record's alarm state explicitly, independent of its value:
# severity=MAJOR, status=STATE
# clear (message defaults to "")
Severity follows the EPICS convention: 0 = NO_ALARM, 1 = MINOR, 2 = MAJOR, 3 = INVALID. The change is published to monitoring clients immediately. Available on scalar and array handles.
The Server class
=
All arguments are keyword-only and optional. Then either:
server.start()— serve on a background thread and return immediately (the usual choice), orserver.run()— serve on the current thread, blocking forever, orserver.start_background()— likestart()but returns aStorehandle.
Other methods:
server.pv(name) -> Pv— mint a typed handle to any served record, including ones loaded from a.dbfile or added via the classic builder. The handle's type is inferred from the record (floats → float, enums → int index, arrays → array). Unknown names raiseKeyError, and so do records with no handle representation (NTTable, NTNDArray, generic structures — use theStorefor those).Server.builder()— static shorthand forspvirit.ServerBuilder().server.store() -> Store— runtime get/set access (see below).server.notifier() -> Notifier— publish monitor updates for source-claimed PVs.server.add_source(label, order, source)— register a dynamic source after construction.
Handles work identically before and after start(): set() on a pending
handle raises RuntimeError; once the server is constructed the handle is
bound and live.
Generating many PVs programmatically
Handles are plain Python objects — build them in loops and comprehensions:
=
=
=
# bind i per-PV
=
# keep the dict for later access
Loading EPICS .db files
Existing StreamDevice/EPICS-style .db files load directly:
=
# or
=
= # typed handle onto a .db-loaded record
Supported record types include ai, ao, bi, bo, stringin,
stringout, mbbi, mbbo, waveform, aai, aao, and common fields
(VAL, DESC, EGU, PREC, HIHI/HIGH/LOW/LOLO, DRVH/DRVL,
ADEL, MDEL, …).
The classic builder API
The fluent ServerBuilder predates the typed handles and remains fully
supported — it also exposes a few record shapes the handle API does not yet
(tables, ND arrays, sub-arrays, generic structures):
=
The full method set: record definitions ai, ao, bi, bo, string_in,
string_out, mbbi, mbbo, waveform, aai, aao, sub_array,
nt_table, nt_ndarray, generic (no longin/longout — 32-bit integer
records exist only in the handle API); loading db_file, db_string;
callbacks on_put, scan; configuration port, udp_port, listen_ip,
advertise_ip, compute_alarms, beacon_period (whole seconds),
add_source; and build().
Differences from the handle API:
on_put(name, callback)is string-keyed and receives(pv_name, value); exceptions are logged, not used to reject the put.scan(name, period, callback)receives the PV name and returns the new value; errors post0.0.build()returns the sameServerclass described above, soserver.pv(name)works to obtain typed handles onto builder-defined records afterwards.- A builder is single-use: any method after
build()raisesRuntimeError. - The builder's record methods take no metadata kwargs (
units=,prec=, …); set fields via a.dbfile or use the handle factories.
Runtime store access
Store (from server.store() or start_background()) provides direct,
name-keyed access to the record store — useful for generic tooling where
typed handles are inconvenient:
=
# -> list[str], all served PVs
# -> scalar value or None
# -> True if the PV exists
# -> full NT payload (NtScalar, ...)
# write a full NT payload
get_nt/put_nt round-trip complete Normative Type payloads including alarm,
timestamp, display, and control substructures — see
Normative Type classes.
Dynamic sources
Sources claim PV names at runtime instead of serving a fixed record store —
the building block for gateways, protocol bridges, and virtual PV namespaces.
A source is any Python object implementing this duck-typed protocol
(each method may be def or async def):
= None
"""Return PvInfo (or a dict) to claim `name`, None to decline."""
return
return None
"""Return the current value as an NT payload."""
return
"""Apply a client write. Raise to reject.
Optionally return NT payload(s) to publish as monitor updates."""
return
# optional: PV listing support
return
# optional: NTURI RPC support
return
# optional: called at registration
= # keep it to push updates later
=
Key points:
claim(name)is called on client search; returnspvirit.PvInfo, a dict{"struct_id": ..., "fields": {...}, "writable": bool}, orNone. Convenience constructors:PvInfo.nt_scalar("double", writable=True),PvInfo.nt_scalar_array("double")(pass the element type), or the fullPvInfo(struct_id, fields, writable=False)wherefieldsmaps field names to type strings ("double","int","string","boolean","double[]","any", …). APvInfoexposes read-only.writableand.struct_idproperties.putreturn values become monitor updates: return one NT payload, a dict{pv_name: payload}, or a list of(pv_name, payload)tuples to fan updates out to related PVs; returnNonefor no propagation. Raising an exception rejects the client's PUT.- Push updates at any time via the
Notifier(fromon_startorserver.notifier()):notifier.notify(pv_name, nt_payload). It is safe to call from any thread, including from inside another source callback. - The
ordernumber decides precedence when several sources could claim a name — lower is tried first; the built-in record store is order 0. async defsource methods run on a dedicated background asyncio event loop (thread namespvirit-asyncio) — do not callasyncio.runyourself inside source methods.
Normative Type classes
Full-fidelity payloads for Store.get_nt/put_nt, sources, and the notifier.
=
# 42.5 (all properties are read-only)
# "mm"
# 0
NtScalar(value, units="", display_low=0.0, display_high=0.0, display_description="", display_precision=0, control_low=0.0, control_high=0.0, control_min_step=0.0, alarm_severity=0, alarm_status=0, alarm_message="")— scalar with display/control/alarm metadata.NtScalarArray(value)— array payload (list or bytes); exposes.value,.alarm,.time_stamp,.display,.control.NtTable— returned by reads (no Python constructor);.labels,.columns() -> dict[str, list],.descriptor,.alarm,.time_stamp.NtNdArray— returned by reads;.value,.dimensions()(list of dicts withsize/offset/full_size/binning/reverse),.unique_id,.compressed_size,.uncompressed_size,.data_time_stamp.- Substructure classes, each with read-only properties matching their
constructor arguments:
Alarm(severity=0, status=0, message="")TimeStamp(seconds_past_epoch=0, nanoseconds=0, user_tag=0)Display(limit_low=0.0, limit_high=0.0, description="", units="", precision=0)Control(limit_low=0.0, limit_high=0.0, min_step=0.0)
- Enum and generic-structure payloads are represented as plain dicts:
enums as
{"index": int, "choices": [...], "selected": str}, generic structures as{"struct_id": ..., <field>: <value>, ...}.
Client API
High-level client for talking to any PVAccess server (spvirit or otherwise):
= # defaults: broadcast search
= # GetResult
# decoded value (dict for NT structures)
# "SIM:TEMP"
, # raw wire bytes, if you need them
# partial pvRequest
# single field: plain str is fine too
# blocking put (fields default: ["value"])
return False # returning False stops the monitor
# blocks until stopped;
# an exception raised in the
# callback stops it and propagates
# {"struct_id": ..., "fields": [...]}
# PV names from a specific server
Non-blocking monitors — subscribe returns immediately with a
Subscription handle while updates are delivered on a background thread:
=
# ... program continues; run as many concurrent subscriptions as you like ...
# "SIM:TEMP"
# True while updates are flowing
# stop promptly (idempotent, works even on a quiet PV)
# None, or the message if the subscription ended on an error
# context manager
...
- The callback receives each update sequentially (per subscription) on a
runtime worker thread; keep it short, and hand heavy work to a queue.
Returning
Falseunsubscribes, exactly likemonitor. - Inside the callback you may call other spvirit operations (
pv.set(),client.get(), …) — re-entrancy is safe. - Failures don't raise (there is no caller to raise into): on a network error
or an exception in the callback, the subscription ends,
is_activebecomesFalse, anderrorholds the message. - Dropping the last reference to a
Subscriptioncloses it — keep the handle alive for as long as you want updates.
Configure with the builder when defaults don't fit:
=
Server discovery (UDP beacons):
(spvirit.lowlevel has its own discover_servers for diagnostic use — note
it returns {"guid": hexstr, "addr": ...} dicts rather than
DiscoveredServer objects, defaults to timeout=1.0, and accepts a
targets= list.)
All client operations block with the GIL released and raise the
SpviritError exception tree on failure. Every
fields= parameter across the client API accepts either a list of dotted
paths or a single string.
Low-level API: spvirit.lowlevel
For repeated operations on one PV, protocol inspection, or precise control,
spvirit.lowlevel.Channel keeps a persistent TCP connection:
, , ,
= # StructureDesc (see codec section)
= # reuses the connection — fast repeated gets
=
# fields: None -> ["value"], or str, or list
# blocks; callback returns False to stop,
# a raised exception stops it and propagates
# or rely on the context manager;
# later operations raise ProtocolError
# async variants: connect_async, get_async, put_async, introspect_async,
# read_packet_async (monitor, close, and read_until are sync-only)
Raw frame access for protocol work:
= # next raw PVA frame -> Packet
, , # raw header fields (ints)
, # e.g. "MONITOR", body length
# raw flag byte; decoded views:
, , , ,
# segmentation flag bits (int, 0 = none)
, , # full frame / payload bytes / frame len
# command-specific decoded dict
# same as codec.decode_packet(pkt.bytes)
= # RuntimeError if max_frames exhausted
Operations on one channel serialize internally, so concurrent get_async
calls on the same channel are safe but sequential on the wire. Monitors send
protocol echo keep-alives automatically.
Discovery utilities (from spvirit.lowlevel import ...), all with *_async
variants where noted:
search_pv(pv_name, udp_port=5076, timeout=3.0, targets=None, debug=False) -> "ip:port"— UDP broadcast search (async:search_pv_async).search_pv_tcp(pv_name, name_server, timeout=3.0, debug=False) -> "ip:port"— search via a TCP name server.discover_servers(udp_port=5076, timeout=1.0, targets=None, debug=False) -> [{"guid": hexstr, "addr": "ip:port"}](async:discover_servers_async).pvlist(server_addr, timeout=5.0) -> (names, source)wheresourcesays how the listing was obtained ("pvlist","getfield","server_rpc","server_get") (async:pvlist_async).parse_addr_list(s),auto_broadcast_targets(),default_search_targets(search_addr=None, bind_addr=None)— address-list helpers honoringEPICS_PVA_ADDR_LIST/EPICS_PVA_AUTO_ADDR_LIST.
targets= accepts a list of (target_ip, bind_ip) tuples or of
{"target": ..., "bind": ...} dicts, so the output of
default_search_targets() / auto_broadcast_targets() can be passed
straight through.
Wire codec: spvirit.codec
Standalone encoders/decoders for PVAccess wire data — useful with
Channel.read_packet, captured traffic, or tests:
= # -> StructureDesc
# readable multi-line layout
# e.g. "epics:nt/NTScalar:1.0"
# FieldDesc: .name, .field_type,
in , # .type_code, .is_array, .struct_desc
= # decode pvData -> Python
# compact one-line rendering
# pull `value` out of an NT dict
# pvRequest bytes (None/empty = all)
# bitset + data for a PUT
# full frame -> dict with magic,
# command_name, flags, details, ...
The wire-level encoders/decoders (decode_introspection, decode_value,
encode_pv_request, encode_put_payload) accept is_be=True for big-endian
streams (default little); format_value, extract_nt_value, and
decode_packet take no endianness flag. StructureDesc also supports
len(desc) for its field count.
Threading and async model
-
The extension runs one shared multi-threaded Tokio runtime per process, created lazily. Blocking methods release the GIL, so other Python threads keep running during network operations.
-
Sync and async mix freely.
set()/get()/Client.get()etc. can be called from any Python thread. The async variants (Pv.set_async/get_async,Channel.connect_async/get_async/put_async, …) return awaitables for use insideasynciocode:await -
Callbacks are re-entrant.
on_put, scan, and calc callbacks run on runtime worker threads while holding the GIL only as needed; inside them you may call blocking handle methods on other PVs (e.g. update a readback from a setpoint'son_put). -
Server.start()serves from a background thread;Server.run()occupies the calling thread. Pythonasync defsource methods execute on a dedicated asyncio loop thread managed by the extension. -
Long-running Python callbacks stall the worker they run on — keep
on_putand scan bodies short, and push slow work to your own threads, publishing results withpv.set(...)ornotifier.notify(...).
Errors and exceptions
Client, channel, discovery, and codec operations raise this hierarchy
(rooted at spvirit.SpviritError):
| Exception | Meaning |
|---|---|
spvirit.TimeoutError |
operation or search timed out (also subclasses builtin TimeoutError) |
spvirit.SearchError |
PV could not be located |
spvirit.ProtocolError |
unexpected/invalid protocol state |
spvirit.DecodeError |
malformed wire data |
spvirit.IoError |
socket/OS-level failure (also subclasses builtin OSError) |
spvirit.PutRejectedError |
a put was rejected by an on_put validator |
spvirit.TimeoutError and spvirit.IoError deliberately dual-inherit, so
idiomatic except TimeoutError: and except OSError: catch them alongside
except spvirit.SpviritError:.
PV-handle operations use builtin exceptions where a builtin is the idiomatic fit:
| Exception | Raised when |
|---|---|
RuntimeError |
handle not yet bound to a server; ServerBuilder method after build() |
KeyError |
server.pv(name) for an unknown or unsupported record |
TypeError |
wrong value type; on_put/scan on an array PV; bad calc inputs; metadata options on an array pv() |
ValueError |
invalid address strings; non-finite floats in client puts |
Examples
Runnable scripts live in
spvirit-py/examples/.
Start with the mailbox pair, then pick by topic:
demo_mailbox.py/demo_mailbox_client.py— the smallest useful IOC (one writable PV) and a client that gets, puts, and subscribes to it.demo_pvfind.py— locate a PV (UDP search or a given address) and print its full structure introspection and current value.demo_wire_inspector.py— decode the actual PVAccess frames behind a get: introspection dump, captured raw bytes throughcodec.decode_packet, and live frames off the TCP stream.demo_gateway.py— a mini-gateway: a dynamic source that proxies another server under aGW:prefix with a put allow-list.demo_expr_namespace.py— an infinite computed namespace: anyEXPR:<expression>PV is evaluated on demand.demo_10k_farm.py— 10,000 PVs plus calc aggregation built in a comprehension, with build/serve/write timings.
The directory also holds demos for every other part of the API (channels, codec, discovery, NT access, and one per dynamic-source pattern).
Building from source
Requires Rust (stable) and Python ≥ 3.9.
&&
Releases are cut by tagging spvirit-py-vX.Y.Z; CI builds abi3 wheels for all
supported platforms plus an sdist and publishes to PyPI via trusted
publishing.
License
BSD-3-Clause. Developed at the ISIS Neutron and Muon Source.