Spvirit
Spvirit is a pure-Rust implementation of the EPICS PVAccess protocol — client, server, wire codec, command-line tools, and Python bindings — with no dependency on an EPICS base installation.
Quickstart
Install. This one command brings the Python module and the twelve sp*
command-line tools; there is no compiler and no Rust toolchain involved on
Linux, macOS, or Windows x86_64.
pip install spvirit
Save this as first.py — it is a complete soft IOC serving three PVs:
temp = spvirit.ai("SIM:TEMPERATURE", 22.5) # input — read-only to clients
setpoint = spvirit.ao("SIM:SETPOINT", 25.0) # output — clients may write
enable = spvirit.bo("SIM:ENABLE", False) # output — a writable bool
server = spvirit.Server(pvs=[temp, setpoint, enable])
server.run()
Run it, and read a PV from a second terminal:
python first.py # terminal 1 — it blocks, serving
$ spget SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-06 09:38:00.729 22.5
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-06 09:38:00.729 30
If you see those three lines you have a working PVAccess server. Nobody
configured a port, an address, or a permission: the client broadcast the PV
name, the server answered, and SIM:TEMPERATURE refused the write it should
refuse because ai is an input record.
The same thing in Rust is Your first PV; every chapter of this book shows both languages.
Where to go next
- Never met EPICS? → EPICS in 10 minutes
- Know EPICS, want a soft IOC? → Your first PV
- Want the whole tour? → Serving scalars and the twelve chapters after it
- Looking up a function? → API reference below
How the site is laid out
- Fundamentals — what EPICS is, what Normative Types are, and the one distinction worth understanding before you write anything: records vs raw NT.
- Getting started — install, then a PV served and read in under a page.
- Progressive examples — thirteen chapters, Rust and Python side by side, ending in a complete IOC.
- Command-line tools — a page per
sp*binary, with real captured output. - Reference — the crate map, the record-type matrix, the Python API, troubleshooting, and the known gaps.
- Developer guide — internals, with file-and-line citations, for people changing spvirit itself.
API reference
This book is the tutorial. The exhaustive, generated API documentation lives on docs.rs, one page per crate:
| Crate | API docs |
|---|---|
spvirit-types | docs.rs/spvirit-types |
spvirit-codec | docs.rs/spvirit-codec |
spvirit-client | docs.rs/spvirit-client |
spvirit-server | docs.rs/spvirit-server |
spvirit-tools | docs.rs/spvirit-tools |
spvirit-calc | docs.rs/spvirit-calc |
For Python there is no generated site; the
Python API chapter is the reference, and every
object carries a docstring, so help(spvirit.ai) works at the prompt.
Project
- Licence — BSD-3-Clause. See Licence and support.
- Source and issues — github.com/ISISNeutronMuon/spvirit
- Contributing — Licence and support has the short version; the Developer guide has the long one.
Every code sample on this site is included verbatim from a file in the repository that is compiled by CI. The badge at the top of each chapter links to that source and to the test that checks it — including the quickstart above:
✅ Verified ·
demo_first_pv.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
Licence and support
Licence
Spvirit is released under the BSD 3-Clause License.
Copyright (c) 2026, Mateusz Leputa for UKRI STFC ISIS Neutron and Muon Facility
In short: use it, modify it, redistribute it, ship it in a commercial product. You must keep the copyright notice and the licence text in source and binary redistributions, and you may not use the names of the copyright holder or its contributors to endorse products derived from it without written permission. There is no warranty.
That is a summary and not the licence. The authoritative text is
LICENSE in
the repository root, and it applies to every crate in the workspace and to
the Python packages built from them.
Getting help
| You want to | Go to |
|---|---|
| Report a bug, or ask a question | GitHub issues |
| Work out why a PV will not connect | Troubleshooting |
| Check whether something is unimplemented rather than broken | Known gaps |
| Look up a Rust API | docs.rs, or the crate map |
| Look up a Python API | Python API |
Before filing a bug, Known gaps is worth two
minutes — it lists the behaviours that are deliberately absent, so you can
tell "not implemented" from "regression". A useful report includes the
spvirit version (python -c "from importlib.metadata import version; print(version('spvirit'))" or cargo tree -p spvirit-server), the platform,
and the smallest server and client pair that reproduces it.
Contributing
Contributions are welcome through pull requests on GitHub.
The short version:
git clone https://github.com/ISISNeutronMuon/spvirit && cd spvirit
cargo build
cargo test --all # must be green before you open a PR
cargo fmt --all
cargo clippy --all-targets
Three conventions specific to this repository:
-
Chapters cite code; they do not copy it. Every code block in this book is an
{{#include}}pointing at a real file underspvirit-*/examples/, anchored with// ANCHOR: name/// ANCHOR_END: name. If you add a snippet, add the example file and the anchor, not a copy of the source. -
docs/book/verify.tomlis the manifest. Each chapter declares the example files, anchors, and CLI tools it cites.spvirit-tools/tests/docs_verify.rschecks that they all exist, that every include is declared, and that every shipped example and tool is documented somewhere. Run it:cargo test -p spvirit-tools --test docs_verifyThe "✅ Verified" badge at the top of each chapter is generated, not typed. After changing what a chapter cites, regenerate:
UPDATE_DOCS=1 cargo test -p spvirit-tools --test docs_verify -
Every example has a Python counterpart, and vice versa. Part III is dual-language throughout; a new Rust example wants a matching
spvirit-py/examples/demo_*.py.
Every page of this book has an edit link in its top-right corner that opens the source file directly on GitHub — the fastest route for a typo or a correction.
The Developer guide is the long version: the crate graph, per-crate internals with file-and-line citations, the test suites and how to run the EPICS interop ones, and the release process.
Citing spvirit
If spvirit is used in published work, cite the repository:
Leputa, M. Spvirit: EPICS PVAccess in pure Rust. UKRI STFC ISIS Neutron and Muon Facility. https://github.com/ISISNeutronMuon/spvirit
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
What is Spvirit?
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Spvirit is a Rust implementation of the EPICS PVAccess protocol: the wire codec, a client, a server, a set of command-line tools, and Python bindings. It talks to EPICS Base, p4p/pvxs, and PVAccessJava, and it needs none of them installed to build or run.
/ˈspɪrɪt/ of the Machine
The six crates
The project is a Cargo workspace, strictly layered. Each crate depends only on the ones above it.
| Crate | What it is |
|---|---|
spvirit-types | Shared data model for PVAccess Normative Types. Pure data, no I/O. |
spvirit-codec | Protocol encoding/decoding and connection state tracking. |
spvirit-client | Search, connect, get, put, monitor. |
spvirit-server | .db parsing, the Source trait, the PVAccess server runtime. |
spvirit-tools | The command-line tools, and the integration test suites. |
spvirit-py | Python bindings via PyO3 — client and server. |
flowchart LR
T["spvirit-types"] --> C["spvirit-codec"]
C --> CL["spvirit-client"]
C --> SV["spvirit-server"]
CL --> TO["spvirit-tools"]
SV --> TO
CL --> PY["spvirit-py"]
SV --> PY
If you only want to read and write PVs from Rust, you need spvirit-client.
If you want to serve them, spvirit-server. Both pull in the two layers below
automatically.
When to reach for it
Spvirit is a good fit when you want to:
- Read or write PVs from a Rust program without linking EPICS Base.
- Stand up a simulator or test double — a handful of PVs that behave like an IOC, in a few lines, started and stopped from a test.
- Bridge something into PVAccess — a REST API, a file, a piece of
hardware with its own protocol — using the
Sourcetrait. - Debug the wire — the tools hex-dump frames, watch search traffic, and byte-compare against captures from other implementations.
- Drive PVs from Python without the p4p build chain.
When not to
Be plain about this: spvirit-server is not a production softIOC
replacement. It implements the record behaviours that matter for
simulation and testing — values, alarms, deadbands, scan and put callbacks,
links — but it is not EPICS Base. It does not implement the full record
processing model, database links with all their link types, or the
sequencing guarantees a real IOC gives you. If you are running a beamline,
run an IOC.
Development is also ongoing rather than finished. The near-term work is expanding the server's softIOC behaviours and record processing, and adding TLS support and structured put payloads to the client.
Where to go next
- New to EPICS? EPICS in 10 minutes.
- Know EPICS, want it running? Installation.
- Want the internals? The Developer guide.
EPICS in 10 minutes
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
If you have never met EPICS, the vocabulary is the hard part. There are only four ideas you need before the rest of this site makes sense.
PVs
A Process Variable (PV) is a named data point — a temperature reading, a
motor position, a shutter state. SIM:TEMPERATURE is a PV name. PVs are what
clients read and write over the network, and the name is the only thing a
client needs to find one: there is no host, no port, no path. A client shouts
the name onto the network and whichever server owns it answers.
That is worth pausing on, because it shapes everything else. PV names are a
flat global namespace. Conventionally they are colon-separated to imply
hierarchy (BEAMLINE:SHUTTER:STATE), but nothing enforces that, and two
servers answering for the same name is a real and confusing failure mode.
Records
On the server side, each PV is backed by a Record. A record has a
type — ai, ao, bi, bo, waveform, and so on — and the type
decides three things: whether clients may write to it, what shape of data it
holds, and what processing happens when it changes.
The naming convention is worth learning because it is not obvious:
imeans input,omeans output — from the IOC's point of view.- An input record (
ai,bi,aai) is read-only to clients. Its value comes from the server: a sensor, a scan callback, a simulation. - An output record (
ao,bo,aao) accepts client writes. ameans analog (a float),bmeans binary (a bool),mbbmeans multi-bit binary (an enum),longmeans 32-bit integer, andwaveform/aai/aaoare arrays.
So ai is "analog input": a read-only float. bo is "binary output": a
writable bool.
Record types at a glance
PvaServer::builder() offers fifteen constructors, but they are not all
the same kind of thing, and the distinction matters more than the list
does.
EPICS record types
These are genuine EPICS record types, documented in the EPICS Base Record
Reference
— the same names, the same i/o conventions, and the same
dbCommon
fields you would find in an IOC's .dbd. They are what you declare in a
.db file, they carry alarm limits and deadbands, and the server processes
them: scanning, link evaluation, automatic timestamps, computed severity.
EPICS Base defines 35 record types; Spvirit implements the 14 below. If you
need calc, calcout, compress, fanout, seq, or the Direct
variants, they are not here — though .link() covers a good deal of what
people reach for calc to do.
| Record type | Rust builder | Direction | Data shape | Typical use |
|---|---|---|---|---|
ai | .ai(name, f64) | Input (read-only) | Scalar | Sensor readings |
ao | .ao(name, f64) | Output (writable) | Scalar | Setpoints, commands |
bi | .bi(name, bool) | Input (read-only) | Boolean | Status bits |
bo | .bo(name, bool) | Output (writable) | Boolean | On/off switches |
stringin | .string_in(name, str) | Input (read-only) | String | Status messages |
stringout | .string_out(name, str) | Output (writable) | String | Text commands |
longin | (handle only) | Input (read-only) | Integer | 32-bit counters |
longout | (handle only) | Output (writable) | Integer | 32-bit settings |
waveform | .waveform(name, data) | Writable | Array | Spectra, traces |
aai | .aai(name, data) | Input (read-only) | Array | Read-only array data |
aao | .aao(name, data) | Output (writable) | Array | Writable array data |
subArray | .sub_array(name, data) | Writable | Array | View into part of an array |
mbbi | .mbbi(name, choices, idx) | Input (read-only) | Enum | Multi-choice status |
mbbo | .mbbo(name, choices, idx) | Output (writable) | Enum | Multi-choice selector |
NT-level constructs — not EPICS records
These have no EPICS equivalent. They exist because PVAccess can carry richer structures than the EPICS record model ever described, and Spvirit lets you serve those structures directly under a PV name.
| Construct | Rust builder | Normative Type | Typical use |
|---|---|---|---|
NtTable | .nt_table(name, table) | NTTable | Tabular data |
NtNdArray | .nt_ndarray(name, arr) | NTNDArray | Image / detector data |
generic | .generic(name, desc, payload) | any | Custom structure |
The difference is not cosmetic. An ai record is a processing entity: it
has SCAN, PINI, FLNK, alarm thresholds, and a deadband, and the server
runs a processing model over it. An NtTable is a payload: a shape on
the wire with a name attached. It has no SCAN, no MDEL, no alarm
computation, and no .FIELD channels, because there is no record underneath
it to have them.
This is the EPICS ideology showing through, and it is worth taking
seriously. EPICS treats a control system as a distributed database of
records that process — the record, not the value, is the unit of meaning.
Alarm state, engineering units, and scan behaviour are properties of the
record, declared once, and every client sees the same story. Reaching for
generic or nt_ndarray steps outside that model: you gain the freedom to
send any structure you like, and you give up everything the record layer was
doing on your behalf.
The full trade-off — and when stepping outside is the right call — is Records vs raw NT.
Two gaps worth knowing before you trip over them
sub_array,nt_table,nt_ndarray, andgenericare builder-only — there is no typedPv<T>handle for them.longin/longoutare the reverse: they exist as typed handles only (Pv::longin(name, i32)), not as builder methods, and.dbloading for them is not wired up —spvirit-server/src/db.rscarries aTODOsaying so. Declaring alonginrecord in a.dbfile will not give you one.
Fields
A record is not just a value. It carries fields: metadata with four-letter uppercase names, inherited from EPICS Base.
| Field | Means |
|---|---|
DESC | Description |
EGU | Engineering units (degC, mm, counts) |
PREC | Display precision (decimal places) |
HOPR / LOPR | High / low operator display range |
DRVH / DRVL | Drive high / low limit — clamps writes |
HIHI / HIGH / LOW / LOLO | Alarm thresholds |
MDEL / ADEL | Monitor / archive deadbands |
ZNAM / ONAM | Zero name / one name, for binary records |
Clients can read fields individually, QSRV-style, by appending .FIELD to
the PV name:
$ spget T:TEMP.EGU
T:TEMP.EGU 2026-08-04 09:36:18.269 degC
A gap worth knowing about. Field access only serves two things: the dbCommon fields every record carries (
DESC,SCAN,PINI,STAT,SEVR,MDEL,ADEL, and about fifteen more), plus any field literally present in a parsed.dbfile.Record-specific fields set programmatically are not served. If you build a PV with
spvirit.ai("X", 22.5, units="degC", prec=2)orPv::ai(...).units("degC"), thenX.EGUandX.PRECdo not resolve — the client times out, because the channel does not exist. The same record loaded from a.dbwithfield(EGU, "degC")servesX.EGUfine.Verified on both paths against
spget. The cause is inspvirit-server/src/record_fields.rs:field_valueconsultsrecord.raw_fields, which is populated by the.dbparser and left empty by the builder and handle APIs. The metadata is still present in the NTScalar'sdisplaystructure either way — only the separate.FIELDchannel is missing.
.db files
Records are usually declared in .db files: plain text, EPICS database
syntax.
record(ai, "SIM:TEMPERATURE") {
field(DESC, "Simulated sensor")
field(EGU, "degC")
field(PREC, "2")
field(HOPR, "100")
field(LOPR, "-20")
}
record(ao, "SIM:SETPOINT") {
field(DESC, "Target temperature")
field(EGU, "degC")
field(DRVH, "100")
field(DRVL, "0")
}
In Spvirit a RecordInstance holds all of it — the record type, the current
value as a Normative Type, and the fields. You can build records three ways,
and they mix freely in one server:
flowchart LR
DB[".db file"] -->|parse_db| RI["RecordInstance"]
Code["Pv::ai(...) handles / builder.ai(...)"] --> RI
RI --> Store["SimplePvStore"]
Store --> Server["PvaServer"]
Server -->|PVAccess protocol| Client["PvaClient"]
Enums, and the ZNAM/ONAM wart
EPICS has no first-class enum type. Binary records (bi/bo) fake one
with two string labels — ZNAM for the zero state, ONAM for the one state:
record(bo, "SHUTTER:CTRL") {
field(ZNAM, "Closed")
field(ONAM, "Open")
}
When a client reads this PV the value is the integer index, 0 or 1, and
display.form.choices carries ["Closed", "Open"] so a UI can draw a
dropdown. In Spvirit, bi/bo store the value as ScalarValue::Bool and the
labels in the znam/onam fields of RecordData::Bi / RecordData::Bo.
For more than two choices, use mbbi/mbbo, which take an explicit list of
choices and map to NTEnum properly.
Channel Access and PVAccess
You will see both names. Channel Access (CA) is the original EPICS protocol; PVAccess (PVA) is its successor, and it is what Spvirit implements. The visible difference is that CA sends bare values while PVA sends structured payloads — value plus alarm plus timestamp plus display metadata, in one message. That structure is the subject of the next chapter.
Default ports: TCP 5075 for data, UDP 5076 for search and beacons.
How it fits together
flowchart TD
subgraph ServerSide["Server side"]
DB[".db file"] -->|load_db / parse_db| Records["HashMap<String, RecordInstance>"]
Handles["Pv::ai() .units() .on_put() ...
typed handles (recommended)"] --> Records
Builder["PvaServer::builder()
.ai() .ao() .bo() ..."] --> Records
Records --> Store["SimplePvStore
(implements Source trait)"]
Store --> Runtime["PvaServer::run()
UDP search + TCP handler + beacons"]
Scan["scan callbacks"] -->|periodic timer| Store
OnPut["on_put callbacks"] -.->|fired after PUT| Store
end
subgraph ClientSide["Client side"]
PC["PvaClient::builder().build()"]
PC -->|pvget| Runtime
PC -->|pvput| Runtime
PC -->|pvmonitor| Runtime
PC -->|pvinfo| Runtime
end
That is the whole vocabulary. Next: what actually travels on the wire.
Further reading
Spvirit follows EPICS conventions rather than inventing its own, so the upstream documentation applies directly. When this site and EPICS Base disagree about what a record type or field means, EPICS Base is right and this is a bug.
- EPICS Base Record Reference
— every record type, field by field. The reference for
ai,ao,waveform,mbbi,subArrayand the rest. - Fields Common to All Record Types
— dbCommon:
DESC,SCAN,PINI,STAT,SEVR, and the others Spvirit serves through field access. - pvAccess Protocol Specification — the wire protocol itself, including the pvData encoding and the default ports.
- pvxs — the modern C++ PVAccess implementation Spvirit most closely mirrors, and the one to compare against when behaviour is ambiguous.
Normative Types
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
PVAccess does not send plain numbers. It sends structured payloads called Normative Types (NT), which wrap the value together with its alarm state, timestamp, display limits, engineering units, and control metadata — all in one message.
This is the single biggest difference from Channel Access, and it is why a
pvget shows you a timestamp and a severity without a second round trip.
flowchart TD
NTP["NtPayload"]
NTP --> NTS["NtScalar"]
NTP --> NTSA["NtScalarArray"]
NTP --> NTT["NtTable"]
NTP --> NTNA["NtNdArray"]
NTP --> NTE["NtEnum"]
NTS --> V1["value: ScalarValue"]
NTS --> A1["alarm severity/status/message"]
NTS --> D1["display: limits, units, precision"]
NTS --> C1["control: limits, min_step"]
NTS --> VA1["valueAlarm: thresholds"]
NTSA --> V2["value: ScalarArrayValue"]
NTSA --> A2["alarm"]
NTSA --> D2["display"]
NTT --> L["labels + columns"]
NTNA --> DIM["dimensions + codec + attributes"]
NTE --> V3["index: u32 + choices: Vec<String>"]
NTE --> A3["alarm"]
The five types
| Normative Type | Rust type | Backed by | Used for |
|---|---|---|---|
| NTScalar | NtScalar | ScalarValue (f64, i32, bool, String, …) | Single-value PVs (ai, ao, bi, bo, …) |
| NTScalarArray | NtScalarArray | ScalarArrayValue (Vec<f64>, Vec<i32>, …) | Array PVs (waveform, aai, aao) |
| NTEnum | NtEnum | index (u32) + choices (Vec<String>) | Multi-bit binary records (mbbi, mbbo) |
| NTTable | NtTable | Named columns of ScalarArrayValue | Tabular data |
| NTNDArray | NtNdArray | ScalarArrayValue + dimensions + attributes | Image / detector data (areaDetector) |
All five live in spvirit-types, and the enum that unifies them is
NtPayload. Everything the server sends and the client receives is one of
these.
What is inside an NTScalar
The value is the small part. An NtScalar also carries:
- alarm — severity, status, and a message string. Severity is the
familiar EPICS ladder:
NO_ALARM,MINOR,MAJOR,INVALID. - timeStamp — seconds past epoch, nanoseconds, and a user tag. The server stamps this automatically for IOC-style records.
- display — units (
EGU), precision (PREC), display limits (HOPR/LOPR), a description, and for binary records theform.choiceslist that carriesZNAM/ONAM. - control — drive limits (
DRVH/DRVL) and a minimum step. - valueAlarm — the
HIHI/HIGH/LOW/LOLOthresholds the server uses to compute severity.
A client does not have to accept all of it. A pvRequest lets you ask for
a subset — value,alarm.severity — and the server sends only those fields.
That is what the --fields flag on the tools does, and it matters for
bandwidth on high-rate monitors.
Structure IDs
Each NT has a type identifier that goes on the wire:
epics:nt/NTScalar:1.0, epics:nt/NTScalarArray:1.0,
epics:nt/NTEnum:1.0, epics:nt/NTTable:1.0, epics:nt/NTNDArray:1.0.
Other implementations key off these strings, which is why interop works at
all — and why a hand-built payload with the wrong ID will be misread by
p4p or pvxs even if its fields are perfect.
Introspection and the FieldDesc cache
Before a client can decode a value it needs the introspection data: the description of the structure's shape. PVAccess sends this once per channel, assigns it a numeric ID, and thereafter sends only the data — the client looks the shape up in a cache.
This is why the first pvget on a channel is larger than subsequent ones,
and why a server that changes a payload's shape mid-stream will confuse
clients. If you work at the raw-NT level, keep the shape stable for the life
of a channel.
Next
The two levels at which you can work with these payloads — letting the record layer manage them for you, or building them by hand — is the subject of Records vs raw NT.
Records vs raw NT
✅ Verified ·
nt_put_get.rs·demo_nt_access.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
Everything on the wire is a Normative Type, but Spvirit gives you two levels at which to work — and it pays to know which one you are on. Choosing wrong is the most common source of "why is my timestamp zero" and "why does my monitor fire on every tick".
The comparison
| IOC-style records | Raw NT payloads | |
|---|---|---|
| You create them with | Pv<T> handles (Pv::ai(...) …), builder methods (.ai(), .waveform() …), .db files | NtScalar/NtScalarArray/… built by hand; hand-built RecordInstance; custom Source impls |
| You read/write | plain values: pv.set(21.5), store.set_value(...) | whole payloads: store.put_nt(...) / get_nt(...), Notifier posts |
| Alarm state | computed for you from HIHI/HIGH/LOW/LOLO limits (compute_alarms), or pv.set_alarm(...) | you set alarm on every payload yourself |
| Timestamps | stamped automatically on every update, client PUT included | yours to fill in — an explicit timeStamp is honoured, a zero one is stamped for you |
| Display/control metadata (EGU, PREC, limits) | record fields, visible QSRV-style (PV.EGU, PV.DESC, …) | whatever you put in the payload, each update |
| Monitor deadbands (MDEL/ADEL) | applied by the server | not applied — every put_nt/notify posts |
| Best for | soft IOCs, simulators, anything that should feel like an EPICS record | gateways/bridges, tables, images, PVs whose metadata changes per update |
Rule of thumb
Stay IOC-style — Pv<T> handles first, .db files for existing databases —
until you need per-update control of the metadata, or a payload shape the
record layer does not model. Then drop to put_nt/get_nt, hand-built
records, or a custom Source.
The two mix freely in one server. store.get_nt() returns the full payload
of an IOC-style record too, so you can start with records and reach through
to the raw layer for the one PV that needs it.
The three consequences that bite
Deadbands only exist at the record level. MDEL and ADEL are applied
by the server when an IOC-style record's value is set. A put_nt bypasses
that entirely: every post goes out. If you have a 1 kHz raw-NT source and a
monitor client, you are sending 1000 updates a second, and no amount of
MDEL in a .db file will change that.
Timestamps are automatic at both levels, and the rule is the same one.
Post a payload — or PUT a record field — with a zero or absent timeStamp
and the server stamps it with the current time; supply a real timeStamp
and it is honoured instead. That is deliberate, so a gateway can forward
the originating acquisition time rather than the time it happened to
relay the value.
At the record level this restamp happens on every accepted PUT, whether or
not the value itself changed — RecordInstance::apply_put
(spvirit-server/src/apply.rs:546) applies value, alarm, display and
control, then always calls set_time_stamp. Server-side updates —
set_value, a scan callback, a .link() recomputation — restamp the same
way. EPICS Base does the same: recGblGetTimeStampSimm() runs
unconditionally in process(), independent of whether the value moved.
Reading and writing shows it happening.
Alarms are only computed at the record level. compute_alarms walks the
HIHI/HIGH/LOW/LOLO thresholds and sets severity. Raw payloads get the
severity you put in them, which for a hand-built NtScalar::from_value is
NO_ALARM — a silently un-alarming PV.
Building a payload by hand
This is what the raw level actually looks like. In Rust, NtScalar starts
from a ScalarValue and the with_* builders each consume and return the
payload, so they chain; fields without a builder are plain pub fields you
assign:
#![allow(unused)] fn main() { /// Build a complete `NtScalar` from scratch: value, metadata, alarm, time. fn make_temp_nt_with_custom_alarm(temp: f64) -> NtScalar { // Custom severity mapping. Nothing computes this for you at the raw-NT // level — a payload you build by hand is NO_ALARM until you say otherwise. // 0 = NO_ALARM, 1 = MINOR, 2 = MAJOR; status is example-only tagging. let (severity, status, message) = if temp >= 22.9 { (2, 3, "custom HIHI") } else if temp >= 22.7 { (1, 1, "custom HIGH") } else if temp <= 21.1 { (2, 5, "custom LOLO") } else if temp <= 21.3 { (1, 4, "custom LOW") } else { (0, 0, "custom OK") }; // The builders are chained on the owned value and each returns `Self`. let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); let mut nt = NtScalar::from_value(ScalarValue::F64(temp)) .with_units("degC".to_string()) .with_description(format!("Simulated temperature ({temp:.2} degC)")) .with_precision(2) .with_limits(20.5, 23.5) // An explicit timestamp is honoured verbatim; leave it unset and the // encoder stamps at encode time instead. .with_timestamp(now.as_secs() as i64, now.subsec_nanos() as i32); // Anything without a builder is a plain public field. nt.alarm_severity = severity; nt.alarm_status = status; nt.alarm_message = message.to_string(); nt } }
Then the payload goes to the store as a whole, and comes back as a whole:
#![allow(unused)] fn main() { // Lower-level NT write (scalar) with custom alarm logic. let temp_nt = make_temp_nt_with_custom_alarm(temp); store.put_nt("SIM:TEMP", NtPayload::Scalar(temp_nt)).await; // Lower-level NT write (array). let samples = (0..8) .map(|i| (t * 0.15 + i as f64 * 0.4).sin()) .collect::<Vec<_>>(); let array_nt = NtScalarArray::from_value(ScalarArrayValue::F64(samples)); store .put_nt("SIM:SPECTRUM", NtPayload::ScalarArray(array_nt)) .await; // Lower-level NT read: the whole payload, not just the value. if let Some(snapshot) = store.get_nt("SIM:TEMP").await { println!("SIM:TEMP => {snapshot:?}"); } }
Python has no builder chain — the same fields are keyword arguments on the
NtScalar constructor:
# Python has no builder chain — every field is a keyword argument on the
# constructor, and anything you leave out keeps its default.
nt_scalar = spvirit.NtScalar(
value=42.0,
units="degC",
display_low=0.0,
display_high=100.0,
display_description="Temperature setpoint",
display_precision=2,
control_low=5.0,
control_high=95.0,
)
# Nothing computes alarms for a payload you built yourself: an NtScalar is
# NO_ALARM unless you pass alarm_severity/alarm_status/alarm_message.
warm = spvirit.NtScalar(
value=96.0,
units="degC",
alarm_severity=1, # 0=NONE 1=MINOR 2=MAJOR 3=INVALID
alarm_status=4,
alarm_message="HIGH",
)
# Write the whole payload, metadata included. put_nt coerces to the record's
# existing wire type and never retypes it.
store.put_nt("NT:SETPOINT", nt_scalar)
and the read side:
# get_nt returns the whole payload — get_value returns only the number.
# It works on IOC-style records too, so you can start with handles and
# reach through to the raw layer for the one PV that needs it.
nt = store.get_nt("NT:TEMPERATURE")
if nt is not None:
print(f" value {nt.value} {nt.units} severity={nt.alarm_severity}")
print(f" display {nt.display_low}..{nt.display_high} prec={nt.display_precision}")
print(f" control {nt.control_low}..{nt.control_high}")
# A name nothing serves gives None rather than raising.
assert store.get_nt("DOES:NOT:EXIST") is None
NtScalarArray, NtTable and NtNdArray follow the same shape. The Python
constructors are spvirit.NtScalar, spvirit.NtScalarArray,
spvirit.NtTable, spvirit.NtNdArray, plus Alarm, TimeStamp, Display
and Control for the substructures.
Note what neither example gets for free: no deadband, no computed alarm. Both are record-level services, and the payload you built is not a record. That is the trade the table above describes.
Which one am I on?
If you called .ai(), .ao(), Pv::ai(), or loaded a .db, you are on the
record level. If you implemented the Source trait or called put_nt, you
are on the raw level. If you called store.set_value() on a record, you are
still on the record level — that is the record API, and it applies deadbands
and stamps time.
Both are covered in Part III: records throughout, and the raw level in Tables and images (payload types the record layer does not model) and Custom data sources (serving PVs without a record store at all).
Installation
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
There are three ways in, depending on what you want to do. None of them require EPICS Base — Spvirit speaks PVAccess itself.
Python
pip install spvirit
That is the whole story on Linux (x86_64, aarch64), macOS (Intel and Apple
Silicon), and Windows x86_64: those five platforms get prebuilt abi3
wheels, so there is no compiler involved. Python 3.9 or newer.
It also brings the command-line tools. spvirit depends on the
spvirit-tools package, so the twelve binaries below land on your PATH
in the same step — no Rust toolchain, and inside the virtualenv rather than
in ~/.cargo/bin.
Check it:
$ python -c "from importlib.metadata import version; print(version('spvirit'))"
0.1.19
(The module itself has no __version__ attribute — ask the package
metadata, as above.)
On any other platform pip falls back to the sdist and builds from source, which needs a Rust toolchain.
Command-line tools
Twelve binaries: spget, spput, spmonitor, spinfo, splist,
spsearch, spexplore, sptable, spserver, spsine, spdodeca, and
spget_compare.
pip install spvirit-tools
Prebuilt wheels for the same five platforms as above, so no compiler is
involved. The binaries install into the environment's bin (or Scripts)
directory, which puts them on PATH whenever that environment is active.
This package is what pip install spvirit pulls in, so if you already have
the bindings you already have these.
Or, with a Rust toolchain:
cargo install spvirit-tools
rustup is the usual way to get one. Stable is what CI
builds against. This route installs into ~/.cargo/bin and is the one to
use on platforms without a prebuilt wheel.
Either way, check it:
$ spget --help
Rust library
Add whichever crates you need. They are strictly layered, so asking for
spvirit-client pulls in spvirit-codec and spvirit-types for you.
[dependencies]
spvirit-client = "0.1" # search, connect, get, put, monitor
spvirit-server = "0.1" # .db parsing, the Source trait, the PVA server
spvirit-codec = "0.1" # low-level PVAccess encode/decode
spvirit-types = "0.1" # the Normative Type data model
Most of this site uses spvirit-client and spvirit-server. You will also
want Tokio — both are async.
From source
For hacking on Spvirit itself, or to run the examples this site includes:
git clone https://github.com/ISISNeutronMuon/spvirit
cd spvirit
cargo build --release
The binaries land in target/release/. Examples run straight from the
workspace:
cargo run -p spvirit-server --example simple_server
Python from source
The bindings are built with maturin:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install maturin
cd spvirit-py
maturin develop --release
After maturin develop the spvirit module is importable from that venv.
A note on ports
PVAccess uses TCP 5075 and UDP 5076 by default, and discovery is UDP broadcast. If a client cannot find a server that is definitely running, the firewall is the first thing to suspect — on Windows especially, and on any host where the two are on different subnets.
Next
Your first PV
✅ Verified ·
simple_server.rs·pvget.rs·demo_first_pv.py·demo_get.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A server holding three PVs, and a client that reads one back. Two terminals.
The three records are deliberately of different kinds — one you can only read, two you can write — because that distinction is the first thing worth internalising.
Rust
The server
#![allow(unused)] fn main() { let server = PvaServer::builder() .ai("SIM:TEMPERATURE", 22.5) .ao("SIM:SETPOINT", 25.0) .bo("SIM:ENABLE", false) .build(); }
PvaServer::builder() collects records, .build() freezes them into a
server, and server.run().await binds the sockets and serves forever. The
full example adds a background task that walks SIM:TEMPERATURE toward
SIM:SETPOINT, but the four lines above are already a working IOC-like
server.
The client
#![allow(unused)] fn main() { let client = PvaClient::builder().build(); let result = if fields.is_empty() { client.pvget(&pv).await? } else { let refs: Vec<&str> = fields.iter().map(String::as_str).collect(); client.pvget_fields(&pv, &refs).await? }; println!("{}: {}", result.pv_name, result.value); }
That sits inside #[tokio::main] async fn main, with pv a String — both
the client and the server are async, so you need a Tokio runtime.
Python
The server
temp = spvirit.ai("SIM:TEMPERATURE", 22.5) # input — read-only to clients
setpoint = spvirit.ao("SIM:SETPOINT", 25.0) # output — clients may write
enable = spvirit.bo("SIM:ENABLE", False) # output — a writable bool
server = spvirit.Server(pvs=[temp, setpoint, enable])
server.run()
The client
client = spvirit.Client()
result = client.get("SIM:TEMPERATURE")
# result.value is the whole NTScalar as a dict — value plus alarm,
# timeStamp, display, control and valueAlarm.
print(result.pv_name, "=", result.value["value"])
print("severity:", result.value["alarm"]["severity"])
The Python client is blocking — client.get(...) returns a value, no
await, no event loop. result.value is the whole NTScalar as a nested
dict, which is why the value itself is result.value["value"].
Run it
# Terminal 1
cargo run -p spvirit-server --example simple_server
# Terminal 2
cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE
Terminal 2 should print the whole structure — this is what success looks like:
$ cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE
SIM:TEMPERATURE: {value=22.500000, alarm={severity=0, status=0, message=""},
timeStamp={secondsPastEpoch=1786008647, nanoseconds=984899400, userTag=0},
display={limitLow=0.000000, limitHigh=0.000000, description="", units="",
precision=0, form={index=0, choices=["Default", "String", "Binary",
"Decimal", "Hex", "Exponential", "Engineering"]}}, control={limitLow=0.000000,
limitHigh=0.000000, minStep=0.000000}, valueAlarm={active=false,
lowAlarmLimit=0.000000, lowWarningLimit=0.000000, highWarningLimit=0.000000,
highAlarmLimit=0.000000, lowAlarmSeverity=0, lowWarningSeverity=0,
highWarningSeverity=0, highAlarmSeverity=0, hysteresis=0}}
That is one long line, wrapped here to fit the page — nothing has been elided. Or with the Python pair:
python spvirit-py/examples/demo_first_pv.py # terminal 1
python spvirit-py/examples/demo_get.py # terminal 2
$ python spvirit-py/examples/demo_get.py
SIM:TEMPERATURE = 22.5
severity: 0
The two halves mix freely: the Rust client reads the Python server, spget
reads either, and so does pvget from EPICS Base.
What to notice
ai is an input; ao and bo are outputs. Input and output are named
from the server's point of view, so an input record is read-only to
clients. Try it:
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spput SIM:TEMPERATURE 99
SIM:TEMPERATURE ERROR protocol error: PUT init error: Write access denied
The server enforces that from the record type alone — you did not configure any permissions.
You get more than a number back. A raw pvget prints the entire
NTScalar: value, alarm, timeStamp, display, control, valueAlarm. The
timestamp is there because the record layer stamped it for you.
$ spget SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-04 09:46:31.420 22.5
spget renders that structure for humans; the example client prints it
whole. Both received exactly the same bytes.
Nobody configured a port or an address. The client broadcast the PV name and the server answered. If that step fails, see the note on ports in Installation.
Next
Serving scalars — engineering units, precision, limits, and the metadata that makes a PV readable.
Serving scalars
✅ Verified ·
scalar_metadata.rs·demo_scalars.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
The same two PVs as Your first PV, but described properly: engineering units, display precision, a description, alarm limits, drive limits, and a monitor deadband.
This is the difference between a number and a reading.
Rust
#![allow(unused)] fn main() { let temperature = Pv::ai("SIM:TEMPERATURE", 22.5) .units("degC") .prec(2) .desc("Sample block temperature") // lolo, low, high, hihi — MAJOR outside the outer pair, // MINOR outside the inner pair. .alarm_limits(0.0, 15.0, 30.0, 40.0) // Monitors stay quiet for changes smaller than this. .mdel(0.5); let setpoint = Pv::ao("SIM:SETPOINT", 25.0) .units("degC") .prec(1) .desc("Demanded temperature") .drive_limits(0.0, 100.0); // `scalar_out`/`scalar_in` pick the wire type from the `ScalarValue` // variant of `initial` — not from Rust's `u16`/`u8`, which have no // native `Pv<T>` handle of their own. This is the route to the eight // NTScalar types `ai`/`ao`/`bi`/`bo`/`longin`/`longout`/`string_in`/ // `string_out` don't cover. let gain = Pv::<ScalarValue>::scalar_out("SIM:GAIN", ScalarValue::U16(1)); let status = Pv::<ScalarValue>::scalar_in("SIM:STATUS", ScalarValue::U8(0)); let server = PvaServer::serve([ AnyPv::from(temperature.clone()), AnyPv::from(setpoint.clone()), AnyPv::from(gain.clone()), AnyPv::from(status.clone()), ]) .build() .await; }
Note what this is not using. PvaServer::builder().ai(name, value)
takes a name and an initial value and nothing else — there is no
.units() on the builder. Metadata is set through typed Pv<T> handles,
then handed to PvaServer::serve([...]). If you need metadata in code,
that is the route.
(The other route is a .db file, covered in
Loading .db files.)
Python
temperature = spvirit.ai(
"SIM:TEMPERATURE",
22.5,
units="degC",
prec=2,
desc="Sample block temperature",
# lolo, low, high, hihi
alarm_limits=(0.0, 15.0, 30.0, 40.0),
# Monitors stay quiet for changes smaller than this.
mdel=0.5,
)
setpoint = spvirit.ao(
"SIM:SETPOINT",
25.0,
units="degC",
prec=1,
desc="Demanded temperature",
drive_limits=(0.0, 100.0),
)
# `type=` picks the wire type by name (or alias, e.g. "u16"); `writable=True`
# serves the output flavor, `False` (default) the input flavor.
gain = spvirit.scalar("SIM:GAIN", 1, type="ushort", writable=True)
status = spvirit.scalar("SIM:STATUS", 0, type="byte")
server = spvirit.Server(pvs=[temperature, setpoint, gain, status])
server.run()
Python has no such split — spvirit.ai() takes all of it as keyword
arguments, and spvirit.Server(pvs=[...]) serves the handles.
What to notice
Metadata rides with the value. A client reading SIM:TEMPERATURE gets
degC and precision: 2 in the same message. That is the NTScalar
display structure from Normative Types,
and it is why a PVAccess GUI can label an axis without being told to.
Alarm limits are four numbers in one call, ordered outward-in:
alarm_limits(lolo, low, high, hihi). Crossing low or high is MINOR;
crossing lolo or hihi is MAJOR. Alarms goes into this.
Drive limits are advertised, not enforced. This one will catch you:
$ spput SIM:SETPOINT 500
SIM:SETPOINT OK
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-04 10:00:31.426 500
drive_limits(0.0, 100.0) populates the NTScalar control structure so
clients know the intended range — and that is all it does. Nothing in the
server clamps a write. If out-of-range values must be rejected, reject
them yourself in an on_put handler; see
Reacting to writes.
MDEL and ADEL behave differently from the rest. They are written
into the record's field table rather than the NT payload, which means
they are the two pieces of metadata you can read back QSRV-style:
$ spget SIM:TEMPERATURE.MDEL
SIM:TEMPERATURE.MDEL 2026-08-04 10:02:48.038 0.5
$ spget SIM:TEMPERATURE.EGU
Error: Timeout("read header")
.EGU times out because there is no such channel. Field access serves the
dbCommon fields plus whatever a parsed .db file contained — and units
set in code goes into the payload, not the field table. The units are still
there; you just have to read the whole PV to see them. This is the gap
described in EPICS in 10 minutes.
Choosing the wire type
ai/ao/bi/bo/longin/longout/string_in/string_out (Rust) and
ai/ao/bo/longin/longout/string_in/string_out (Python) each
fix the NTScalar wire type to one of double, boolean, int or
string. PVAccess defines twelve NTScalar value types in total —
boolean, byte, short, int, long, their unsigned variants
(ubyte, ushort, uint, ulong), float, double, and string — and
reaching the other eight needs an explicit type selection.
Rust
#![allow(unused)] fn main() { // `scalar_out`/`scalar_in` pick the wire type from the `ScalarValue` // variant of `initial` — not from Rust's `u16`/`u8`, which have no // native `Pv<T>` handle of their own. This is the route to the eight // NTScalar types `ai`/`ao`/`bi`/`bo`/`longin`/`longout`/`string_in`/ // `string_out` don't cover. let gain = Pv::<ScalarValue>::scalar_out("SIM:GAIN", ScalarValue::U16(1)); let status = Pv::<ScalarValue>::scalar_in("SIM:STATUS", ScalarValue::U8(0)); }
Pv::<ScalarValue>::scalar_out/scalar_in build a record whose wire type
is whatever ScalarValue variant initial holds — scalar_out for a
writable PV, scalar_in for read-only.
Python
# `type=` picks the wire type by name (or alias, e.g. "u16"); `writable=True`
# serves the output flavor, `False` (default) the input flavor.
gain = spvirit.scalar("SIM:GAIN", 1, type="ushort", writable=True)
status = spvirit.scalar("SIM:STATUS", 0, type="byte")
spvirit.scalar(name, initial, *, type, writable=False, **opts) picks the
wire type by name (or alias, e.g. "u16"/"i32"); writable=True serves
the output flavor. The full type-name/alias table and the value coercion
rules (overflow, widening, narrowing) are in
spvirit-py's README, NT scalar type coverage
section
— the same reference the Python API page
points to for pv/scalar.
Run it
# Terminal 1
cargo run -p spvirit-server --example scalar_metadata
# or: python spvirit-py/examples/demo_scalars.py
# Terminal 2
spget SIM:TEMPERATURE
spget SIM:TEMPERATURE.MDEL
spput SIM:SETPOINT 30
spinfo SIM:GAIN
spinfo SIM:STATUS
Terminal 2 prints:
$ spget SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-06 09:13:06.435 22.5
$ spget SIM:TEMPERATURE.MDEL
SIM:TEMPERATURE.MDEL 2026-08-06 09:13:06.707 0.5
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
spinfo prints the type of every field. Only the third line differs
between the two records — SIM:GAIN is a ushort, SIM:STATUS a ubyte
— and the remaining forty-odd lines are the same NTScalar skeleton in both:
$ spinfo SIM:GAIN
SIM:GAIN:
struct epics:nt/NTScalar:1.0
value: ushort
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
display: structure
limitLow: double
limitHigh: double
description: string
units: string
precision: int
form: structure
index: int
choices: string[]
control: structure
limitLow: double
limitHigh: double
minStep: double
valueAlarm: structure
active: boolean
lowAlarmLimit: double
lowWarningLimit: double
highWarningLimit: double
highAlarmLimit: double
lowAlarmSeverity: int
lowWarningSeverity: int
highWarningSeverity: int
highAlarmSeverity: int
hysteresis: ubyte
$ spinfo SIM:STATUS
SIM:STATUS:
struct epics:nt/NTScalar:1.0
value: ubyte
...
That is the point of the narrow types: the wire carries two bytes for a
ushort and one for a ubyte, but the surrounding structure — alarm,
timestamp, display, control, valueAlarm — is identical whatever the value
type is.
Next
Reading and writing
✅ Verified ·
pvget.rs·pvput.rs·demo_get.py·demo_put.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
Client code that reads a PV and writes one, plus the CLI equivalents you will reach for far more often than you expect.
Rust
Read
#![allow(unused)] fn main() { let client = PvaClient::builder().build(); let result = if fields.is_empty() { client.pvget(&pv).await? } else { let refs: Vec<&str> = fields.iter().map(String::as_str).collect(); client.pvget_fields(&pv, &refs).await? }; println!("{}: {}", result.pv_name, result.value); }
Write
#![allow(unused)] fn main() { let client = PvaClient::builder().build(); if fields.is_empty() { client.pvput(&pv, value).await?; } else { let refs: Vec<&str> = fields.iter().map(String::as_str).collect(); client.pvput_fields(&pv, value, &refs).await?; } println!("OK"); }
Both are async and both need a Tokio runtime. PvaClient::builder()
takes the network settings — .port(), .udp_port(), timeouts — and
.build() gives you a client you can reuse for many operations. Building
one client per PV works, but you pay for discovery every time.
The _fields variants take a list of dotted paths and send them as a
pvRequest, so the server returns only that subtree.
Python
Read
client = spvirit.Client()
result = client.get("SIM:TEMPERATURE")
# result.value is the whole NTScalar as a dict — value plus alarm,
# timeStamp, display, control and valueAlarm.
print(result.pv_name, "=", result.value["value"])
print("severity:", result.value["alarm"]["severity"])
Write
client = spvirit.Client()
client.put("SIM:SETPOINT", 30.0)
print("after put:", client.get("SIM:SETPOINT").value["value"])
The Python client is blocking — no await, no event loop. Both get and
put take the same optional fields argument as their Rust counterparts.
From the command line
$ spget SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-04 09:46:31.420 22.5
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
spget formats the payload for humans; spget --fields value,alarm.severity
narrows it. For everything the tools take, see
Command-line tools.
What to notice
A GET returns the whole structure, not a number. In Rust
result.value is a DecodedValue; in Python result.value is a nested
dict, so the number itself is result.value["value"]. Everything else —
alarm, timeStamp, display, control, valueAlarm — arrived in the same
message and cost you nothing extra.
A PUT targets a field, and that field defaults to value. Python's
put(pv, v) is put(pv, v, fields=["value"]). That is why writing to a PV
does not wipe its alarm state or its units: you addressed one leaf of the
structure.
Writing to an input record fails at the protocol level.
$ spput SIM:TEMPERATURE 99
SIM:TEMPERATURE ERROR protocol error: PUT init error: Write access denied
The refusal comes back on the PUT init exchange, before any value is sent. The record type alone decides it.
A client PUT always restamps the record. Server-side updates —
store.set_value(), a scan callback, a .link() recomputation — set the
record's timeStamp to now, and an accepted PUT does exactly the same,
whether or not the value it carried actually moved:
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-04 10:00:31.426 500
$ spput SIM:SETPOINT 42 && spget SIM:SETPOINT
SIM:SETPOINT OK
SIM:SETPOINT 2026-08-04 10:02:47.913 42
This matches EPICS Base, where recGblGetTimeStampSimm() runs
unconditionally in process(). The one wrinkle: if the PUT carried its own
non-zero timeStamp, that value is honoured instead of server time — a
gateway relaying a reading from elsewhere can forward the originating
acquisition time rather than the moment it happened to relay it.
Run it
# Terminal 1
cargo run -p spvirit-server --example scalar_metadata
# Terminal 2
cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE
cargo run -p spvirit-client --example pvput -- SIM:SETPOINT 30
python spvirit-py/examples/demo_put.py
The raw client prints the whole structure; pvput prints nothing but
OK; and the Python pair prints just the field each one asked for:
$ cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE
SIM:TEMPERATURE: {value=22.500000, alarm={severity=0, status=0, message=""},
timeStamp={secondsPastEpoch=1786008647, nanoseconds=984899400, userTag=0},
display={...}, control={...}, valueAlarm={...}}
$ cargo run -p spvirit-client --example pvput -- SIM:SETPOINT 30
OK
$ python spvirit-py/examples/demo_put.py
after put: 30.0
(The {...} groups above are elided; see
Your first PV for the untrimmed dump.)
A successful PUT returns no value — if you want to see the new number you
have to read it back, which is exactly what demo_put.py does:
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-06 09:30:35.355 30
Next
Monitoring changes
✅ Verified ·
pvmonitor.rs·demo_monitor.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A subscription: one request, then updates pushed by the server for as long as you care to listen. This is what PVAccess is actually for — polling a PV in a loop is almost always the wrong answer.
Rust
#![allow(unused)] fn main() { let client = PvaClient::builder().build(); let cb = |value: &spvirit_codec::spvd_decode::DecodedValue| { println!("{value}"); ControlFlow::Continue(()) }; let refs: Vec<&str> = fields.iter().map(String::as_str).collect(); if let Some(q) = pipeline { client .pvmonitor_with_options(&pv, &refs, MonitorOptions::pipelined(q), cb) .await?; } else if fields.is_empty() { client.pvmonitor(&pv, cb).await?; } else { client.pvmonitor_fields(&pv, &refs, cb).await?; } }
The callback returns a ControlFlow: Continue(()) to keep going,
Break(()) to unsubscribe. pvmonitor runs until the callback breaks or
the connection drops.
MonitorOptions::pipelined(q) asks the server for flow control with a
queue depth of q — useful on high-rate PVs where a slow consumer would
otherwise fall behind.
Python
client = spvirit.Client()
seen = 0
def on_update(value):
global seen
seen += 1
print(f"{seen}: {value['value']:.3f}")
return seen < 5 # returning False ends the monitor
client.monitor("SIM:TEMPERATURE", on_update)
client.monitor(...) blocks until the callback returns False or
raises. For a non-blocking version use client.subscribe(...), which
returns a Subscription you can close(), and which runs the callback on
a background thread:
sub = client.subscribe("SIM:TEMPERATURE", on_update)
...
sub.close()
If a subscription ends on a network error, sub.error holds the message
and sub.is_active becomes False — worth checking, because a silently
dead subscription looks exactly like a quiet PV.
From the command line
$ spmonitor SIM:TEMPERATURE
What to notice
The first update is the current value. A subscription delivers the present state immediately, then changes. You do not need a GET before a monitor.
Monitors respect the record's MDEL. This is the single most useful
thing on this page. A record with mdel=1.0 posts an update only when the
value has moved at least 1.0 from the last posted value — not from the
last set value. Writing 0.1, 0.2, 0.3, 5.0, 5.1, 5.2, 20.0 to such a PV
delivers three updates:
posted to monitor: [0.0, 5.0, 20.0]
0.0 is the initial value on subscribe; 5.0 and 20.0 each cleared the
deadband. The intermediate writes landed in the record — a GET would show
5.2 — they just were not broadcast.
MDEL defaults to 0, meaning no deadband. A record you never gave an
mdel posts every change. On a 1 kHz PV with a hundred subscribers, that
is a decision, so make it deliberately.
Severity changes always get through. The deadband is bypassed when the
alarm severity changes, so a PV crossing into MAJOR is never silently
swallowed by a large MDEL.
ADEL is not the same thing. ADEL is the archive deadband; it is
parsed, stored, and served over field access, but PVAccess monitors use
MDEL.
Deadbands are a record-level feature. A raw-NT source posting with
put_nt has no MDEL to consult, and every post goes out. See
Records vs raw NT.
Run it
# Terminal 1
python spvirit-py/examples/demo_scan.py
# Terminal 2
cargo run -p spvirit-client --example pvmonitor -- SIM:TEMPERATURE
# or
python spvirit-py/examples/demo_monitor.py
# or
spmonitor SIM:TEMPERATURE
demo_scan.py updates ten times a second, so terminal 2 fills immediately:
$ spmonitor SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-06 09:25:29.221 21.677613
SIM:TEMPERATURE 21.734644
SIM:TEMPERATURE 21.8092
SIM:TEMPERATURE 21.89275
SIM:TEMPERATURE 21.956506
SIM:TEMPERATURE 22.051046
SIM:TEMPERATURE 22.151852
SIM:TEMPERATURE 22.240413
SIM:TEMPERATURE 2026-08-06 09:25:30.028 22.347997
SIM:TEMPERATURE 22.440441
SIM:TEMPERATURE 22.534399
...
spmonitor prints the timestamp only on the first update of each
wall-clock second — that is a display convenience, not a change in the
data. Every update carries a full timestamp on the wire. Counting the
lines between two timestamps is a quick way to see your actual update rate.
Stop it with Ctrl-C.
Next
Discovery and introspection
✅ Verified ·
pvlist.rs·demo_discovery.py·demo_pvfind.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
Four questions, in the order you actually hit them: what servers are on this
network, which one has my PV, what else does it serve, and what shape is that
PV? These are the library forms of splist and
spinfo — the same four calls those tools make.
Discovery, listing, and introspection are metadata-only — none of the Rust
steps below reads a value. The Python introspection snippet does end with a
get() to show the current value alongside the field table, but that call
is separate from introspect() itself.
Rust
Find servers. A UDP search with no PV name attached: every PVA server that hears it answers with its GUID and TCP address.
#![allow(unused)] fn main() { let servers = discover_servers(udp_port, timeout, &targets, false).await?; for server in &servers { let guid: String = server.guid.iter().map(|b| format!("{b:02X}")).collect(); println!("GUID 0x{guid} tcp {}", server.tcp_addr); } }
build_search_targets(None, None) produces the target list the way EPICS
Base does — EPICS_PVA_ADDR_LIST merged with auto-discovered broadcast
addresses, unless EPICS_PVA_AUTO_ADDR_LIST disables the latter. Pass
Some(ip) as the first argument to pin a single target.
Locate a PV. The same search, narrowed to one name:
#![allow(unused)] fn main() { let server_addr = search_pv(&pv, udp_port, timeout, &targets, false).await?; println!("{pv} is served by {server_addr}"); }
List a server's PVs. This one takes a SocketAddr, not a name — which is
why the two steps above come first:
#![allow(unused)] fn main() { let client = PvaClient::builder().timeout(timeout).build(); let (names, source) = client.pvlist_with_fallback(server_addr).await?; println!("{} PVs via {source:?}", names.len()); for name in &names { println!(" {name}"); } }
Describe a PV. This step builds its own PvaClient and resolves the PV
name directly, so it does not need the address search_pv found above.
pvinfo returns a StructureDesc; format_structure_tree renders it the way
spinfo does:
#![allow(unused)] fn main() { let client = PvaClient::builder().timeout(timeout).build(); let desc = client.pvinfo(&pv).await?; println!("{}", format_structure_tree(&desc)); }
Python
The same four beats. Discovery and listing live in spvirit.lowlevel:
print("\ndiscover_servers(timeout=1.5) ...")
try:
servers = ll.discover_servers(timeout=1.5)
except Exception as e: # noqa: BLE001
print(f" discovery failed: {e}")
servers = []
for s in servers:
print(f" guid={s['guid']} addr={s['addr']}")
if not servers:
print(" (no servers responded — run a spserver locally to see results)")
if len(sys.argv) > 1:
pv = sys.argv[1]
print(f"\nsearch_pv({pv!r}, timeout=2.0) ...")
try:
addr = ll.search_pv(pv, timeout=2.0)
print(f" -> {addr}")
except Exception as e: # noqa: BLE001
print(f" not found: {e}")
This continues straight from discovery, reusing the servers list that
discover_servers returned:
if servers:
addr = servers[0]["addr"]
print(f"\npvlist({addr!r}) ...")
try:
names, source = ll.pvlist(addr, timeout=3.0)
print(f" via {source}: {len(names)} names")
for n in names[:10]:
print(f" {n}")
except Exception as e: # noqa: BLE001
print(f" pvlist failed: {e}")
Introspection goes through a channel. demo_pvfind.py walks the returned
description with a small recursive walk() helper — defined just above this
block in the file — that yields one row per field, descending into
field.struct_desc wherever a field is itself a structure; formatting each
value's type goes through codec, spvirit.codec, imported at the top of the
same file:
with Channel.connect(pv_name, addr) as ch:
desc = ch.introspect()
print(f"\nPV : {pv_name}")
print(f"server : {addr} (sid={ch.sid})")
print(f"struct_id : {desc.struct_id}")
print(f"fields : {len(desc)}\n")
width = max(len(name) for name, _, _ in walk(desc))
for name, ftype, is_array in walk(desc):
suffix = "[]" if is_array and not ftype.endswith("[]") else ""
print(f" {name:<{width}} {ftype}{suffix}")
Channel.introspect() is the low-level route, and the one to use when you
want the channel open anyway for a subsequent get(). For a one-shot
summary there is also Client.info(pv_name), but it is flatter than
introspect()'s result: a top-level dict of {struct_id, fields: [{name, field_type}]}, with no nesting into sub-structures and no is_array flag.
Reach for Channel.introspect() when you need the full recursive
description; Client.info when a flat top-level summary is enough.
Client.pvlist(addr) is the __pvlist-only convenience — it returns just
the name list and has none of lowlevel.pvlist's fallback chain, so it
fails outright on servers where the fallback would have succeeded. Use
lowlevel.pvlist when you need that fallback chain or want to know which
route answered.
From the command line
$ splist
$ splist 127.0.0.1:5075
$ spinfo VAC:PRESSURE
What to notice
Listing needs an address, not a name. pvlist takes a SocketAddr
because listing is a question about a server, while a PV name is a question
about the network. If all you have is a PV name, search_pv (Rust) or
lowlevel.search_pv (Python) converts one into the other. Rust also offers
resolve_pv_server, which applies the full PvGetOptions — name servers,
explicit --server, the lot — rather than a bare broadcast.
The second return value names the route that worked. pvlist_with_fallback
tries four strategies in turn and tells you which answered:
PvListSource::PvList, GetField, ServerRpc, or ServerGet. Python returns
it as the second element of a (names, source) tuple, with source spelled
as one of the strings "pvlist", "getfield", "server_rpc", or
"server_get" — that is the source the Python snippet above prints. It
matters because the routes differ in completeness — a server answering by
ServerGet may be giving you a truncated view. The
splist page has the detail.
Introspection transfers no data. pvinfo uses CMD_GET_FIELD (0x11),
so the server replies with a type description and no value. That makes it
safe on PVs a GET would choke on — a 4-megapixel image, or a PV whose read
triggers expensive device I/O.
__pvlist is in every listing. It is the server's own introspection
channel, not one of your PVs. Filter it out if you are building a UI.
Discovery is a UDP broadcast. On a multi-homed host the search can leave
by the wrong interface and find nothing. build_search_targets(Some(ip), None)
or EPICS_PVA_ADDR_LIST pins it.
Run it
# Terminal 1 — something to talk to
cargo run -p spvirit-server --example complete_ioc
# Terminal 2
cargo run -p spvirit-client --example pvlist
# or, once you have an address from the line above
cargo run -p spvirit-client --example pvlist -- 127.0.0.1:5075
# or, to describe one PV
cargo run -p spvirit-client --example pvlist -- VAC:PRESSURE
# or, the Python equivalents
python spvirit-py/examples/demo_discovery.py
python spvirit-py/examples/demo_pvfind.py VAC:PRESSURE
Bare pvlist finds servers:
$ cargo run -p spvirit-client --example pvlist
GUID 0x60C70000640AEFC9B42DC918 tcp 10.64.23.134:5075
An address argument lists that server's PVs:
$ cargo run -p spvirit-client --example pvlist -- 127.0.0.1:5075
6 PVs via PvList
VAC:ERROR
VAC:LINK
VAC:PRESSURE
VAC:RGA
VAC:SETPOINT
__pvlist
A PV name searches for it, then describes it:
$ cargo run -p spvirit-client --example pvlist -- VAC:PRESSURE
VAC:PRESSURE is served by 10.64.23.134:5075
struct epics:nt/NTScalar:1.0
value: double
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
display: structure
limitLow: double
limitHigh: double
description: string
units: string
precision: int
form: structure
index: int
choices: string[]
control: structure
limitLow: double
limitHigh: double
minStep: double
valueAlarm: structure
active: boolean
lowAlarmLimit: double
lowWarningLimit: double
highWarningLimit: double
highAlarmLimit: double
lowAlarmSeverity: int
lowWarningSeverity: int
highWarningSeverity: int
highAlarmSeverity: int
hysteresis: ubyte
The GUID and address will differ on your machine.
Next
Reacting to writes
✅ Verified ·
on_put.rs·on_put_reject.rs·demo_on_put.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A writable PV that runs your code when a client writes to it — and, in the form that supports it, refuses writes it does not like.
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() .ao("SIM:SETPOINT", 25.0) .on_put("SIM:SETPOINT", |pv, val| { println!("{pv} was set to {val:?}"); }) .build(); }
That is the builder form, and it is worth reading its signature carefully:
#![allow(unused)] fn main() { Fn(&str, &DecodedValue) }
It returns (). It runs after the value has been applied, and it has no
way to say no. It is a notification hook, not a validator.
To reject a write, use the typed handle form instead:
#![allow(unused)] fn main() { Fn(&Pv<T>, T) -> Result<(), String> }
#![allow(unused)] fn main() { let setpoint = Pv::ao("SIM:SETPOINT", 25.0) .units("degC") .drive_limits(0.0, 100.0) .on_put(|pv, value: f64| { if !(0.0..=100.0).contains(&value) { // Err rejects the PUT; the client's put() fails. return Err(format!("{} outside 0..100: {value}", pv.name())); } println!("{} accepted {value}", pv.name()); Ok(()) }); let server = PvaServer::serve([setpoint.clone()]).build().await; }
Err(msg) rejects the PUT on the wire and the client's put fails:
$ spput SIM:SETPOINT 500
SIM:SETPOINT ERROR protocol error: PUT failed: SIM:SETPOINT outside 0..100: 500
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-04 10:08:05.123 30
The rejected value never reached the record.
Ok(()) accepts it. You also get the value already converted to T
instead of a raw DecodedValue.
The two forms are not interchangeable, and the builder form's inability to reject is the single most common surprise in this API. If you are validating, use handles.
Python
setpoint = spvirit.ao("SIM:SETPOINT", 25.0, drive_limits=(0.0, 100.0))
@setpoint.on_put
def _on_setpoint(pv, value):
print(f"{pv.name} was set to {value}")
if value > 100.0:
return False # reject the PUT on the wire
server = spvirit.Server(pvs=[setpoint])
Python has one form and it can reject: returning False — or raising —
rejects the PUT, and anything else accepts it. The callback runs before
the value is applied.
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spput SIM:SETPOINT 500
SIM:SETPOINT ERROR protocol error: PUT failed: rejected by on_put
What to notice
Attach callbacks before serving. on_put, scan, and calc must be
attached to a PV before it is handed to Server(...) / PvaServer::serve.
Attaching afterwards is a silent no-op — the core logs a warning and
carries on. Nothing raises, nothing fails; your callback simply never runs.
This is true in both languages.
Validation is the only enforcement you get. Drive limits are advisory
(see Serving scalars), so on_put is where range checking
actually happens. If a PV must not accept 500, write that rule here.
The callback is not a place to block. It runs on the server's runtime. Long work belongs in a task you spawn from it.
A single client write can invoke your callback more than once. spput
tries the full PUT flow first and silently falls back to the simple flow if
that fails, so a rejected write arrives at the server twice and your
callback logs twice:
$ spput SIM:SETPOINT 700
SIM:SETPOINT ERROR protocol error: PUT failed: rejected by on_put
# server log
SIM:SETPOINT was set to 700.0
SIM:SETPOINT was set to 700.0
Accepted writes run once; only the retry path doubles up. Pass
--no-flow-fallback to suppress it. The general rule holds regardless of
client: on_put callbacks should be idempotent, and side effects that
must happen exactly once do not belong in one.
Array PVs do not support on_put or scan in Python. Calling either
on an array PV raises TypeError. Drive arrays with pv.set(...) from
your own loop instead — see Arrays and waveforms.
Run it
# Terminal 1
cargo run -p spvirit-server --example on_put
# or: python spvirit-py/examples/demo_on_put.py
# Terminal 2
spput SIM:SETPOINT 30
spput SIM:SETPOINT 500
on_put only observes — both writes succeed, and the interesting output
is in terminal 1:
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spput SIM:SETPOINT 500
SIM:SETPOINT OK
# terminal 1
SIM:SETPOINT was set to Structure([("value", Float64(30.0))])
SIM:SETPOINT was set to Structure([("value", Float64(500.0))])
The callback receives the whole submitted structure, not a bare number —
a client may write value alone or several fields at once.
Now run on_put_reject instead, which returns an error for out-of-range
values:
cargo run -p spvirit-server --example on_put_reject
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
$ spput SIM:SETPOINT 500
SIM:SETPOINT ERROR protocol error: PUT failed: SIM:SETPOINT outside 0..100: 500
Error: Protocol("PUT failed: SIM:SETPOINT outside 0..100: 500")
Your message crosses the wire verbatim, so write it for whoever is holding
the terminal. spput also exits non-zero, which is why it prints that
second Error: line — useful in a script.
Terminal 1 logs only the write it let through:
SIM:SETPOINT accepted 30
Next
Simulating a device
✅ Verified ·
scan_callback.rs·linked_calc.rs·demo_scan.py·demo_calc.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
PVs that change on their own — a periodic scan, and computed PVs that recalculate whenever their inputs move. Together these are most of what a test double needs.
Periodic scanning
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() .ai("SIM:TEMPERATURE", 22.5) .scan("SIM:TEMPERATURE", Duration::from_millis(100), |_pv| { let t = TICK.fetch_add(1, Ordering::Relaxed) as f64; ScalarValue::F64(22.5 + (t * 0.1).sin()) }) .build(); }
Python
temp = spvirit.ai("SIM:TEMPERATURE", 22.5, units="degC", prec=2)
@temp.scan(period=0.1)
def _simulate(pv):
return 22.5 + math.sin(time.monotonic())
server = spvirit.Server(pvs=[temp])
@temp.scan(period=0.1) is the decorator form; temp.scan(0.1, fn) is the
same thing as a call.
Computed PVs
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() // Writable inputs .ao("CALC:A", 0.0) .ao("CALC:B", 0.0) // Computed outputs (read-only) .ai("CALC:SUM", 0.0) .ai("CALC:PROD", 0.0) .ai("CALC:MEAN", 0.0) // Links: recomputed whenever CALC:A or CALC:B changes .link("CALC:SUM", &["CALC:A", "CALC:B"], |v| { ScalarValue::F64(f64_of(&v[0]) + f64_of(&v[1])) }) .link("CALC:PROD", &["CALC:A", "CALC:B"], |v| { ScalarValue::F64(f64_of(&v[0]) * f64_of(&v[1])) }) .link("CALC:MEAN", &["CALC:A", "CALC:B"], |v| { ScalarValue::F64((f64_of(&v[0]) + f64_of(&v[1])) / 2.0) }) .build(); }
.link(output, inputs, compute) recomputes output whenever any of
inputs changes. There is also Pv::calc(name, &[&inputs], f) on the
handle side, which is the same idea with f64 types filled in for you.
Python
a = spvirit.ao("CALC:A", 0.0)
b = spvirit.ao("CALC:B", 0.0)
total = spvirit.calc("CALC:SUM", [a, b], lambda v: v[0] + v[1])
product = spvirit.calc("CALC:PROD", [a, b], lambda v: v[0] * v[1])
mean = spvirit.calc("CALC:MEAN", [a, b], lambda v: (v[0] + v[1]) / 2.0)
server = spvirit.Server(pvs=[a, b, total, product, mean])
What to notice
Scan and calc fail differently, and neither one shouts.
| On a callback exception | |
|---|---|
scan | logs the error, re-posts the last value the scan produced (or the type default — 0.0/False/0/"" — if it has never produced one) |
calc | logs the error, posts 0.0 |
So a calc that starts throwing pins its PV at zero, which looks exactly
like a real reading of zero. If a computed PV must distinguish "broken"
from "zero", set an alarm severity explicitly — see Alarms.
A scan returning None does not mean "leave it alone". It re-posts the
last value that scan produced. It does not read the PV's current value,
so if something else called pv.set(...) in between, returning None will
overwrite that with the scan's own cached value. Return a real value, or
call pv.set() and let the scan return None deliberately.
Computed PVs are read-only. .link() and calc produce ai records.
Writing to one either fails or is immediately overwritten on the next
recomputation.
Recomputation is change-driven, not periodic. Nothing happens until an
input changes. A calc over two inputs that never move costs nothing.
Deadbands apply to the output too. If a computed PV has an mdel, its
subscribers see the deadbanded stream even though the recomputation ran.
Run it
# Terminal 1
cargo run -p spvirit-server --example linked_calc
# Terminal 2
spput CALC:A 10
spput CALC:B 3
spget CALC:SUM # 13
spget CALC:PROD # 30
spget CALC:MEAN # 6.5
spmonitor CALC:SUM # live updates as A or B change
$ spput CALC:A 10
CALC:A OK
$ spput CALC:B 3
CALC:B OK
$ spget CALC:SUM
CALC:SUM 2026-08-06 09:13:28.993 13
$ spget CALC:PROD
CALC:PROD 2026-08-06 09:13:28.993 30
$ spget CALC:MEAN
CALC:MEAN 2026-08-06 09:13:28.993 6.5
All three derived PVs carry the same timestamp, because one write to
CALC:B recomputed all of them in the same pass.
Leave spmonitor CALC:SUM running first, then do the two puts from a third
terminal, and you can watch the recomputation happen:
$ spmonitor CALC:SUM
CALC:SUM 2026-08-06 09:27:29.216 0
CALC:SUM 2026-08-06 09:27:34.375 10
CALC:SUM 2026-08-06 09:27:35.262 13
CALC:SUM 2026-08-06 09:27:36.132 23
0 is the initial value delivered on connect, 10 follows spput CALC:A 10, 13 follows spput CALC:B 3, and 23 is a later spput CALC:A 20.
Each input write produces exactly one output update.
Or the scan pair:
python spvirit-py/examples/demo_scan.py # terminal 1
spmonitor SIM:TEMPERATURE # terminal 2
SIM:TEMPERATURE 2026-08-06 09:25:29.221 21.677613
SIM:TEMPERATURE 21.734644
SIM:TEMPERATURE 21.8092
SIM:TEMPERATURE 21.89275
...
SIM:TEMPERATURE 2026-08-06 09:25:30.028 22.347997
Ten updates a second, forever, with no client asking for them. The timestamp is reprinted only when the wall-clock second rolls over.
Next
Arrays and waveforms
✅ Verified ·
waveform.rs·demo_waveform.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A 1024-point spectrum, updated ten times a second. Same shape as a detector trace, a scope capture, or an image row.
Rust
Serve it:
#![allow(unused)] fn main() { let server = PvaServer::builder() .waveform("SIM:SPECTRUM", ScalarArrayValue::F64(vec![0.0; 1024])) .build(); }
Update it:
#![allow(unused)] fn main() { tokio::spawn(async move { const N: usize = 1024; let mut tick = 0u64; loop { let phase = (tick as f64) * 0.03; let samples = (0..N) .map(|i| { let x = i as f64; (phase + x * 0.02).sin() + 0.25 * (phase * 0.5 + x * 0.05).cos() }) .collect::<Vec<_>>(); store .set_array_value("SIM:SPECTRUM", ScalarArrayValue::F64(samples)) .await; tick += 1; tokio::time::sleep(std::time::Duration::from_millis(100)).await; } }); }
store.set_array_value(name, ScalarArrayValue::F64(v)) replaces the whole
array. ScalarArrayValue has a variant per element type — F64, I32,
U8, Bool, Str, and the rest — and the variant is fixed when the
record is created.
Python
spectrum = spvirit.waveform("SIM:SPECTRUM", [0.0] * 1024)
server = spvirit.Server(pvs=[spectrum])
server.start()
tick = 0
while True:
phase = tick * 0.03
spectrum.set([
math.sin(phase + i * 0.02) + 0.25 * math.cos(phase * 0.5 + i * 0.05)
for i in range(1024)
])
tick += 1
time.sleep(0.1)
Note server.start() rather than server.run(). run() blocks forever;
start() returns so the loop below it can drive the PV.
The three array record types
| Builder | Record | Clients may write |
|---|---|---|
.waveform(name, data) | waveform | yes |
.aai(name, data) | aai | no |
.aao(name, data) | aao | yes |
Pick aai for anything a client should only read — a detector spectrum, a
computed histogram. Pick aao or waveform for a lookup table or a
scan trajectory a client is meant to load.
.sub_array(name, data, indx, nelm) serves a window into a larger array,
which is the EPICS subArray record.
What to notice
Python array setters take a list. A numpy array is not a list —
call .tolist() first:
spectrum.set(my_numpy_array.tolist())
The one exception is U8 arrays, which also accept bytes. type=
selects the element type explicitly when the Python values are ambiguous:
spvirit.waveform("IMG", data, type="ushort").
on_put and scan are not available on array PVs in Python. Both
raise TypeError. Drive an array from your own loop with pv.set(...),
as the example above does.
Arrays carry their own timestamp handling. Unlike NtScalar — where a
None timestamp makes the encoder stamp at encode time — array payloads
encode the timestamp they hold verbatim. The server stamps on every
set_array_value, so this only matters if you build payloads by hand at
the raw-NT level.
Every update sends the whole array. There is no delta encoding. A
1024-point f64 waveform at 10 Hz is about 80 kB/s per subscriber. MDEL
does not help here — the deadband gate only applies to numeric scalars,
so an array posts on every change.
Run it
# Terminal 1
cargo run -p spvirit-server --example waveform
# or: python spvirit-py/examples/demo_waveform.py
# Terminal 2
spget SIM:SPECTRUM
spmonitor SIM:SPECTRUM
Brace yourself: spget prints all 1024 elements on one line. There is
no truncation anywhere in the tool chain.
$ spget SIM:SPECTRUM
SIM:SPECTRUM 2026-08-06 09:14:04.728 [0.279967, 0.299451, 0.318292, 0.336483,
0.354022, 0.370907, ... 1.163854, 1.153073]
(Elided here for the page — your terminal shows every value.) Pipe it
somewhere if you want to keep it: spget SIM:SPECTRUM > spectrum.txt.
spinfo is the readable view. Note the type ID and the [] on value:
$ spinfo SIM:SPECTRUM
SIM:SPECTRUM:
struct epics:nt/NTScalarArray:1.0
value: double[]
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
display: structure
limitLow: double
limitHigh: double
description: string
units: string
precision: int
control: structure
limitLow: double
limitHigh: double
minStep: double
There is no valueAlarm — array records have no limit checking to do. And
spmonitor SIM:SPECTRUM re-sends the whole 1024-element array on every
update; PVAccess has no partial-array delta.
Next
Enums and binary records
✅ Verified ·
exotic_nt.rs·demo_enums.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
PVs whose value is one of a fixed set of named states — the EPICS mbbi
and mbbo records, carried on the wire as an NtEnum.
What an enum record is
An NtEnum value is not a string. It is a structure:
value:
index: int # which choice is selected
choices: string[] # the labels, in order
The record stores the index. The labels ride along so a client can
render "Running" without a separate lookup. This is why spget prints
{index=2, choices=["Idle", "Running", "Fault"]} rather than Fault.
| Record | Direction | EPICS field names for choices |
|---|---|---|
bi / bo | in / out | ZNAM, ONAM (exactly two) |
mbbi / mbbo | in / out | ZRST…FFST (up to sixteen) |
bi/bo are not NtEnum in spvirit — they are NtScalar booleans, and
spget prints true/false. ZNAM/ONAM name the two states in a .db
file. If you need the labels on the wire, use mbbi/mbbo.
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() .mbbi( "SIM:STATE", vec![ "Idle".to_string(), "Running".to_string(), "Error".to_string(), ], 0, ) .mbbo( "SIM:MODE", vec![ "Standby".to_string(), "Acquire".to_string(), "Calibrate".to_string(), ], 0, ) .generic( "SIM:POSITION", "demo:custom/Position:1.0", vec![ ("x".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))), ("y".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))), ( "label".to_string(), PvValue::Scalar(ScalarValue::Str("origin".to_string())), ), ], ) .build(); }
Python
STATES = ["Idle", "Running", "Fault"]
MODES = ["Standby", "Acquire", "Calibrate"]
# mbbi is read-only over the wire; mbbo accepts client writes.
state = spvirit.mbbi("SIM:STATE", STATES, 0, desc="Machine state")
mode = spvirit.mbbo("SIM:MODE", MODES, 0, desc="Requested mode")
server = spvirit.Server(pvs=[state, mode])
server.start()
# The value is the choice *index*, not the label.
for i in range(len(STATES)):
state.set(i)
print(f"SIM:STATE = {i} ({STATES[i]})")
time.sleep(1)
What to notice
Enum records ignore the scalar metadata options. units, prec,
adel, mdel, and the limit setters do not apply — only desc is
accepted. An NtEnum has no engineering units to carry.
.db files cannot define enum records. mbbi and mbbo parse, then
fail at construction with:
Record 'DEMO:STATE': type 'mbbi' is not a standard EPICS Base record type
and cannot be loaded from .db files
The message is misleading — mbbi is a standard EPICS Base record type;
it is spvirit's .db loader that does not build it
(spvirit-server/src/db.rs:550). Build enum records in code.
Writing to an mbbo from a client does not work today. The record is
advertised writable and the PUT is accepted on the wire — spput prints
OK, the Python client's put() returns without raising — but the value
does not change:
$ spput SIM:MODE --json '{"value":{"index":2}}'
SIM:MODE OK
$ spget SIM:MODE
SIM:MODE {index=0, choices=["Standby", "Acquire", "Calibrate"]}
The store's enum PUT branch (spvirit-server/src/simple_store.rs:616)
matches only a bare integer under value, but the NTEnum wire format nests
the index one level deeper as value.index, so the update is dropped
silently. Drive enum records server-side with pv.set(index) and treat
them as read-only from the client until this is fixed.
Out-of-range indices are rejected, not clamped. The store checks
idx < 0 || idx >= choices.len() and leaves the value alone.
Run it
# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_enums.py
# Terminal 2
spget SIM:STATE
spmonitor SIM:STATE
$ spget SIM:STATE
SIM:STATE 2026-08-06 09:14:32.958 {index=0, choices=["Idle", "Running", "Error"]}
$ spinfo SIM:STATE
SIM:STATE:
struct epics:nt/NTEnum:1.0
value: structure
index: int
choices: string[]
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
value is a structure, not a number — that is what makes NTEnum
different from an NTScalar holding an integer.
Under a monitor the difference shows up on the wire:
$ spmonitor SIM:STATE
SIM:STATE 2026-08-06 09:27:05.724 {index=0, choices=["Idle", "Running", "Error"]}
SIM:STATE 2026-08-06 09:27:06.731 {index=1}
SIM:STATE 2026-08-06 09:27:07.748 {index=2}
SIM:STATE 2026-08-06 09:27:08.763 {index=0}
The choice list arrives once, in the first update, and is then omitted because it did not change. PVAccess sends a change bitset, not a whole structure. A client that only reads the field it was given on each update will lose the labels after the first one — cache them.
Next
Alarms and severity
✅ Verified ·
alarms.rs·demo_alarms.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
PVs that tell a client not just what the value is but whether it should be worried about it.
The alarm structure
Every NtScalar carries an alarm block alongside its value:
alarm:
severity: int # 0 NONE, 1 MINOR, 2 MAJOR, 3 INVALID
status: int # EPICS status code
message: string # free text
Severity is the part clients act on. A control screen turns a widget yellow on MINOR and red on MAJOR; an archiver may record the transition even when the value itself is inside the deadband.
Alarm transitions always post. The MDEL deadband gates value changes
only — a severity change reaches every subscriber regardless of how small
the value moved (spvirit-server/src/simple_store.rs:545).
Two ways to get a severity
| How | Evaluated by | |
|---|---|---|
| Computed | LOW/HIGH/LOLO/HIHI in a .db file, plus .compute_alarms(true) | the server, on every write |
| Explicit | set_alarm(severity, status, message) | you |
Computed, from a .db file
record(ao, "DEMO:SETPOINT") {
field(LOW, "10") # MINOR below this
field(HIGH, "40") # MINOR above this
field(LOLO, "5") # MAJOR below this
field(HIHI, "45") # MAJOR above this
}
With .compute_alarms(true) the server re-evaluates on every write:
$ spput DEMO:SETPOINT 41 && spget DEMO:SETPOINT
DEMO:SETPOINT 41 MINOR READ HIGH
$ spput DEMO:SETPOINT 46 && spget DEMO:SETPOINT
DEMO:SETPOINT 46 MAJOR READ HIHI
$ spput DEMO:SETPOINT 3 && spget DEMO:SETPOINT
DEMO:SETPOINT 3 MAJOR READ LOLO
$ spput DEMO:SETPOINT 25 && spget DEMO:SETPOINT
DEMO:SETPOINT 25
compute_alarms defaults to false. Without it the limits are
published but never compared against anything.
Explicit
Rust
#![allow(unused)] fn main() { let pressure = Pv::ao("SIM:PRESSURE", 50.0) .units("bar") .desc("Vessel pressure") // lolo, low, high, hihi — published to clients, not evaluated. .alarm_limits(5.0, 10.0, 90.0, 110.0); let link = Pv::ai("SIM:LINK", 0.0).desc("Device link health"); let server = PvaServer::serve([pressure.clone(), link.clone()]) .compute_alarms(true) .build() .await; }
#![allow(unused)] fn main() { // Alarms you decide yourself, independent of the value. This is the // route that works for handle-built PVs. // severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID link.set_alarm(3, 17, "device unreachable").await?; }
Python
Same two halves. alarm_limits=(lolo, low, high, hihi) is a keyword
argument rather than a builder call:
pressure = spvirit.ai(
"SIM:PRESSURE",
50.0,
units="bar",
desc="Vessel pressure",
# lolo, low, high, hihi — published to clients, not evaluated.
alarm_limits=(5.0, 10.0, 90.0, 110.0),
)
link = spvirit.ai("SIM:LINK", 0.0, desc="Device link health")
server = spvirit.Server(pvs=[pressure, link], compute_alarms=True)
server.start()
# severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID
# status is an EPICS status code; the message is free text.
link.set_alarm(3, 17, "device unreachable")
# Deciding severity yourself, from the value. This is the route that works
# for handle-built PVs, since the limits above are never compared.
for reading in (50.0, 95.0, 120.0):
pressure.set(reading)
if reading >= 110.0:
pressure.set_alarm(2, 4, "HIHI")
elif reading >= 90.0:
pressure.set_alarm(1, 4, "HIGH")
else:
pressure.set_alarm(0, 0, "")
print(f"SIM:PRESSURE = {reading}")
time.sleep(1)
What to notice
.alarm_limits() is published but not evaluated. This is the trap in
this chapter. Calling .alarm_limits(lolo, low, high, hihi) on a Pv
handle — or passing alarm_limits=(...) in Python — fills in the
valueAlarm structure, and clients can read those limits back with
spinfo. But the value is never compared against them, even with
.compute_alarms(true):
$ spget SIM:PRESSURE
SIM:PRESSURE 50
$ spput SIM:PRESSURE 95 && spget SIM:PRESSURE
SIM:PRESSURE 95 # no MINOR, despite high = 90
The cause is two parallel sets of fields. Pv::alarm_limits writes
nt.value_alarm_* (spvirit-server/src/pv.rs:329), while the evaluator
update_alarm_from_value reads nt.alarm_low/alarm_high/alarm_lolo/
alarm_hihi (spvirit-types/src/lib.rs:285) — and only the .db loader
populates those (spvirit-server/src/db.rs:360).
So today: computed alarms require a .db file. For handle-built PVs,
call set_alarm yourself, as the examples above do.
INVALID is for "I don't know", not "bad value". Severity 3 means the reading cannot be trusted — the device is unreachable, the scan threw, the link is down. A value that is simply too high is MAJOR, not INVALID. This matters because clients treat INVALID as "ignore this number".
A failing calc posts 0.0 with severity NONE. It looks like a
genuine reading of zero. If a computed PV must distinguish broken from
zero, set INVALID explicitly — see Simulating a device.
set_alarm bypasses the deadband and does not evaluate links. It is a
direct write to the alarm block.
Run it
# Terminal 1
cargo run -p spvirit-server --example alarms
# or: python spvirit-py/examples/demo_alarms.py
# Terminal 2
spget SIM:LINK # INVALID device unreachable
spmonitor SIM:PRESSURE
$ spget SIM:LINK
SIM:LINK 2026-08-06 09:14:45.114 0 INVALID SIMM device unreachable
$ spget SIM:PRESSURE
SIM:PRESSURE 2026-08-06 09:14:45.114 50
spget appends severity, status and message after the value when the
alarm is not NO_ALARM — and prints nothing extra when it is, which is why
SIM:PRESSURE at 50 looks like a plain reading.
The Rust alarms example is static, so spmonitor SIM:PRESSURE shows one
line and then waits. Use the Python example to watch the limits fire:
python spvirit-py/examples/demo_alarms.py # terminal 1
spmonitor SIM:PRESSURE # terminal 2
SIM:PRESSURE 2026-08-06 09:26:17.437 50
SIM:PRESSURE 2026-08-06 09:26:18.438 95
SIM:PRESSURE {alarm={severity=1, status=4, message="HIGH"}} MINOR HIGH
SIM:PRESSURE 2026-08-06 09:26:19.439 120
SIM:PRESSURE {alarm={severity=2, status=4, message="HIHI"}} MAJOR HIGH HIHI
Two things worth pausing on. The value and the alarm arrive as separate
updates — the server changed value first, then the record layer
evaluated the limits and changed alarm. And the second line of each pair
shows only the field that changed, because a monitor sends a change bitset;
spmonitor renders the changed subtree and then the decoded severity.
Next
Serving a .db file
✅ Verified ·
db_file.rs·example.db·demo_db_file.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A soft IOC defined the EPICS way — as a database file rather than as code.
This is the point where spvirit stops being "a PVAccess library" and starts being "an IOC you can drop into an existing control system". The same file an EPICS IOC would load, spvirit serves.
The database
# A minimal EPICS database, loadable by both `spserver --db` and
# `PvaServer::builder().db_file(...)`. Field names are the ordinary EPICS
# ones; fields spvirit does not model are ignored rather than rejected.
#
# Note: mbbi/mbbo are NOT loadable from .db in spvirit today - build enum
# records in code instead. See the Enums chapter.
# Read-only analog input. Clients can get and monitor it; puts are refused.
record(ai, "DEMO:TEMP") {
field(DESC, "Sample block temperature")
field(VAL, "22.5")
field(EGU, "degC")
field(PREC, "2")
field(LOPR, "0") # display low - a hint for GUIs
field(HOPR, "50") # display high
field(MDEL, "0.5") # monitor deadband
}
# Writable analog output, with alarm limits the server evaluates.
record(ao, "DEMO:SETPOINT") {
field(DESC, "Demanded temperature")
field(VAL, "25.0")
field(EGU, "degC")
field(PREC, "1")
field(LOW, "10") # MINOR below this
field(HIGH, "40") # MINOR above this
field(LOLO, "5") # MAJOR below this
field(HIHI, "45") # MAJOR above this
field(DRVL, "0") # advisory only - spvirit does not clamp
field(DRVH, "100")
}
record(bo, "DEMO:ENABLE") {
field(DESC, "Master enable")
field(VAL, "0")
field(ZNAM, "DISABLED")
field(ONAM, "ENABLED")
}
record(waveform, "DEMO:SPECTRUM") {
field(DESC, "8-point detector trace")
field(FTVL, "DOUBLE")
field(NELM, "8")
field(VAL, "0, 1, 4, 9, 16, 9, 4, 1")
}
A .db file is a list of record(type, "NAME") { field(FIELD, "value") }
blocks. Fields spvirit does not model are ignored rather than rejected, so
a database written for a real IOC generally loads unchanged — you just get
fewer behaviours than EPICS Base would give you.
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() .db_file("spvirit-server/examples/example.db") // .db LOW/HIGH/LOLO/HIHI are only evaluated when this is on. .compute_alarms(true) .build(); }
.db_string(content) takes the same syntax from a string, which is
convenient in tests.
Python
db_file= and db_string= are keyword arguments on Server, and mix
freely with pvs=[...] handles in the same server:
server = spvirit.Server(
db_file="spvirit-server/examples/example.db",
# .db LOW/HIGH/LOLO/HIHI are only evaluated when this is on.
compute_alarms=True,
)
# `db_string="record(ai, \"X\") { field(VAL, \"1\") }"` takes the same
# syntax inline, which is what you want in a test.
A .db record arrives without a handle. Server.pv(name) mints one,
typed from the record's wire type:
# A .db-loaded record has no handle. `Server.pv()` mints one, typed from
# the record's wire type, so you can drive it like any other:
temp = server.pv("DEMO:TEMP")
temp.set(23.4)
print(f"DEMO:TEMP = {temp.get()}")
# The handle is *already bound* to the served record, so `on_put`, `scan`
# and `calc` are silently ignored on it — those must be attached to an
# unbound handle before the server is built. A .db record therefore cannot
# have a write validator; declare it with `spvirit.ao(...)` if it needs one.
That handle is already bound, which is the catch worth knowing before
you plan around it: on_put, scan and calc are only honoured on an
unbound handle, so attaching one to a .db record does nothing (the core
logs a warning and carries on). A record that needs a write validator or a
scan callback has to be declared in code with spvirit.ao(...) rather than
loaded from the file. The same is true in Rust — PvaServer::pv() attaches,
it does not re-declare. See Reacting to writes.
From the command line
No code at all:
spserver --db spvirit-server/examples/example.db
Fields that do something
| Field | Effect |
|---|---|
VAL | initial value |
DESC | description, served in display.description |
EGU | engineering units |
PREC | display precision |
LOPR / HOPR | display limits (a GUI hint; nothing enforces them) |
LOW / HIGH | MINOR alarm limits — evaluated, given .compute_alarms(true) |
LOLO / HIHI | MAJOR alarm limits — same |
MDEL | monitor deadband |
ADEL | archive deadband |
DRVL / DRVH | drive limits — advisory only, spvirit does not clamp |
SCAN | "1 second" etc., for periodic reprocessing |
INP | input link, for scanned records that copy another PV |
FTVL / NELM | element type and count, for array records |
ZNAM / ONAM | the two state names of a bi/bo |
INDX / MALM | window offset and max length, for subArray |
What to notice
.db is the only route to computed alarms. As
Alarms explains, the handle API's .alarm_limits() publishes
limits without evaluating them. LOW/HIGH/LOLO/HIHI in a .db file
are evaluated. If you want the server to derive severity from the value,
this is how.
Input records refuse writes. ai, bi, stringin, aai are
read-only on the wire, and the refusal is explicit rather than silent:
$ spput DEMO:TEMP 30
DEMO:TEMP ERROR protocol error: PUT init error: Write access denied
Use the o variants — ao, bo, stringout, aao, waveform — for
anything a client should set.
mbbi/mbbo cannot be loaded from .db. They parse and are then
rejected at construction. Build enum records in code — see
Enums.
longin/longout are not recognised by the .db parser either. They
exist in the handle API only (spvirit-server/src/types.rs:48).
Loading is best-effort per record. A record spvirit cannot build logs to stderr and is skipped; the rest of the file still serves. Check the log rather than assuming every PV made it.
Run it
# Terminal 1
cargo run -p spvirit-server --example db_file
# or: python spvirit-py/examples/demo_db_file.py
# Terminal 2
splist
spget DEMO:TEMP
spput DEMO:SETPOINT 46
spget DEMO:SETPOINT # MAJOR HIHI
$ splist 127.0.0.1:5075
DEMO:ENABLE
DEMO:SETPOINT
DEMO:SPECTRUM
DEMO:TEMP
__pvlist
$ spget DEMO:TEMP
DEMO:TEMP 2026-08-06 09:14:58.052 22.5
$ spput DEMO:SETPOINT 46
DEMO:SETPOINT OK
$ spget DEMO:SETPOINT
DEMO:SETPOINT 2026-08-06 09:14:58.052 46 MAJOR READ HIHI
$ spput DEMO:SETPOINT 25
DEMO:SETPOINT OK
$ spget DEMO:SETPOINT
DEMO:SETPOINT 2026-08-06 09:14:58.052 25
Four PVs and no Rust that names any of them — every field, including the
HIHI limit that turned 46 into a MAJOR alarm, came out of the .db file.
The alarm clears on its own when the value drops back inside the limits;
nothing acknowledged it.
__pvlist in the listing is the server's own directory record, not one of
yours. It is how splist works at all.
Next
Tables and images
✅ Verified ·
exotic_nt.rs·demo_table.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
An NtTable — columnar data, like a scan result or a device inventory —
and an NtNdArray — a framed image.
These are the two places where spvirit steps outside the EPICS record
model. As Records vs raw NT
explains, there is no table record in EPICS Base. An NtTable is a
payload, not a processing entity: nothing scans it, nothing computes
alarms from it, and there is no .db syntax that produces one. You are
using PVAccess as a transport for structured data.
NtTable
A table is a set of equal-length named columns plus display labels:
labels: string[] # what to show in a header row
value:
x: double[] # one array per column
y: double[]
Rust
#![allow(unused)] fn main() { let table_nt = NtTable { labels: vec!["X".to_string(), "Y".to_string()], columns: vec![ NtTableColumn { name: "x".to_string(), values: ScalarArrayValue::F64(xs), }, NtTableColumn { name: "y".to_string(), values: ScalarArrayValue::F64(ys), }, ], descriptor: Some("SIM table demo".to_string()), alarm: None, time_stamp: None, }; store.put_nt("SIM:TBL", NtPayload::Table(table_nt)).await; }
Python
# An NTTable is a dict of equal-length columns.
# Labels default to the column names.
builder.nt_table("SIM:TBL", {"x": [0.0] * 8, "y": [0.0] * 8})
Python's builder takes a plain {name: list} dict and infers each column's
wire type; types= overrides that per column. labels defaults to the
column names.
NtNdArray
An image is flat data plus a dimension list. The dimensions carry offset, binning and reversal so a client can reconstruct a region of interest without a second PV.
Rust
#![allow(unused)] fn main() { let ndarray_nt = NtNdArray { value: ScalarArrayValue::U8(pixels), codec: NdCodec { name: "none".to_string(), parameters: HashMap::new(), }, compressed_size: 16, uncompressed_size: 16, dimension: vec![ NdDimension { size: 4, offset: 0, full_size: 4, binning: 1, reverse: false, }, NdDimension { size: 4, offset: 0, full_size: 4, binning: 1, reverse: false, }, ], unique_id: tick as i32, data_time_stamp: Default::default(), attribute: vec![], descriptor: Some("SIM 4x4 image".to_string()), alarm: None, time_stamp: None, display: None, }; store .put_nt("SIM:IMG", NtPayload::NdArray(ndarray_nt)) .await; }
Python
The Python builder is the compact form of the same thing:
# An NTNDArray is flat data plus dimensions. On the builder each dimension
# is a (size, full_size) pair: the served extent and the extent of the
# underlying frame.
builder.nt_ndarray("SIM:IMG", [0] * 16, [(4, 4), (4, 4)], type="ubyte")
Driving both
Neither type accepts a client PUT, so the server updates them with
store.put_nt(...) — the raw-NT level from
Records vs raw NT:
# Neither type is writable over the wire, so the server drives both with
# put_nt. The payload constructors take dimensions as a flat list of sizes,
# where the builder takes (size, full_size) pairs.
for tick in range(20):
xs = [float(i) for i in range(8)]
ys = [math.sin(v * 0.7 + tick * 0.15) for v in xs]
store.put_nt("SIM:TBL", spvirit.NtTable({"x": xs, "y": ys}, labels=["X", "Y"]))
frame = [(i * 16 + tick * 8) % 256 for i in range(16)]
store.put_nt("SIM:IMG", spvirit.NtNdArray(frame, [4, 4], type="ubyte"))
time.sleep(0.5)
Watch the dimension argument, which is the one place the two APIs disagree:
the builder takes (size, full_size) tuples, while the NtNdArray
constructor takes a flat list of sizes and sets full_size equal to each
size. Offset, binning and reversal are not reachable from the Python
constructor at all; build the payload in Rust if you need a region of
interest.
What to notice
Both types accept a wire PUT. RecordInstance::apply_put
(spvirit-server/src/apply.rs:609) dispatches NtTable and NtNdArray PUTs
to apply_table_put/apply_ndarray_put, so a client write updates the
record and restamps it like any other. The examples above still use
store.put_nt(...) because that is the natural way to drive a
server-to-client payload from Rust or Python code, not because a client PUT
is refused.
No deadband, no alarm computation, no scanning. All of that lives in
the record layer, which these types are not part of. Every put_nt posts
to every subscriber.
spget prints them structurally.
$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, ...], y=[0.000000, 0.644218, ...]}
$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, ...]}
The ubyteValue field name is not decoration — NTNDArray's value is a
union, and the field name identifies which arm is populated. An int16
image would come back as shortValue.
There is no table viewer in the toolbox. spget prints the shape,
and that is the extent of it. sptable is a
server, not a client — an interactive spreadsheet IOC that serves an
NtTable — so it is the right tool for producing test data, not for
inspecting someone else's PV.
Element types are fixed at creation. Writing an f64 column into a
table created with int columns coerces to the record's type rather than
retyping the record. The record is the authority.
Python needs lists, not numpy arrays. Call .tolist() — bytes is
also accepted for ubyte data.
Run it
# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_table.py
# Terminal 2
spget SIM:TBL
spget SIM:IMG
$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, 2.000000, 3.000000,
4.000000, 5.000000, 6.000000, 7.000000], y=[0.000000, 0.644218, 0.985450,
0.863209, 0.334988, -0.350783, -0.871576, -0.982453]}
$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, 48, 64, 80, 96, 112,
128, 144, 160, 176, 192, 208, 224, 240]}
Both are one long line, wrapped here. y is sin(x) and the image is a
4×4 greyscale ramp — small enough to read, which is the whole point of the
example. Note what spget does not print: the table's labels
(["X", "Y"]), and the image's dimension array. Both are on the wire;
spget renders the value field only.
spinfo SIM:IMG shows why NTNDArray is the most involved normative type in
the book — value is a union of twelve typed arrays, and the shape lives
in a separate dimension structure array:
$ spinfo SIM:IMG
SIM:IMG:
struct epics:nt/NTNDArray:1.0
value: union
booleanValue: array
byteValue: array
shortValue: array
intValue: int[]
...
stringValue: string[]
codec: structure
name: string
parameters: any
compressedSize: long
uncompressedSize: long
dimension: structure[]
size: int
offset: int
fullSize: int
binning: int
reverse: boolean
uniqueId: int
dataTimeStamp: structure
...
attribute: structure[]
name: string
value: any
descriptor: string
sourceType: int
source: string
descriptor: string
alarm: structure
...
Only the arm you filled in is transmitted, so a ubyte image does not pay
for the eleven other array types.
Next
Custom data sources
✅ Verified ·
multi_source.rs·wildcard_source.rs·json_source.rs·rpc_source.rs·demo_source_multi.py·demo_source_wildcard.py·demo_source_sensor.py·demo_source_rpc.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
A PV provider that is not a record store at all — PVs backed by a file, by another process, by a naming convention, or computed on demand.
Everything so far has used the built-in record store: you declare PVs up front and the server holds their values. A source replaces that with your own code. The server asks "do you own this name?" and, if you say yes, routes every GET, PUT and subscribe to you.
The Source trait
#![allow(unused)] fn main() { fn claim(&self, name: &str) -> Option<PvInfo>; // do I own this name? fn get(&self, name: &str) -> Option<NtPayload>; fn put(&self, name, value) -> Result<Vec<(String, NtPayload)>, String>; fn subscribe(&self, name) -> Option<Receiver<NtPayload>>; fn rpc(&self, name, args) -> Result<NtPayload, String>; // has a default fn names(&self) -> Vec<String>; }
(Shown with the Pin<Box<dyn Future<...>>> wrappers elided — the trait is
object-safe rather than async fn, so every method returns a boxed future.
See spvirit-server/src/pvstore.rs:55.)
claim is the interesting one. It runs on every channel search, and
returning Some(PvInfo) commits you to serving that name.
In Python there is no trait to implement — a source is any object with the matching methods, checked by duck typing:
class MySource:
def claim(self, name): ... # -> PvInfo | None
def get(self, name): ... # -> NtScalar/NtScalarArray/... | None
def put(self, name, value): ... # -> payload, or raise to reject
def rpc(self, name, args): ... # optional
def names(self): ... # -> list[str]
def on_start(self, notifier): ... # optional: stash the notifier
Two differences from Rust worth knowing up front. on_start has no Rust
counterpart — it is how a Python source gets the Notifier it needs to push
monitor updates. And subscribe is not part of the Python protocol: define
it and it is ignored (spvirit-py/src/source.rs:535). Monitors are driven by
notifier.notify(name, payload) instead.
Priority and the registry
Sources are registered with an integer order. Lower is checked first, and the built-in record store sits at 0:
#![allow(unused)] fn main() { let server = PvaServer::builder() .ai("SIM:COUNTER", 0.0) // ConstSource at order -10 — checked before the built-in store .source("constants", -10, Arc::new(ConstSource::new())) // ComputedSource at order 10 — checked after the built-in store .source("computed", 10, Arc::new(ComputedSource)) .build(); }
# Lower order is asked first; the built-in record store sits at 0.
# Fallback claims every name, so it goes last.
server = (
spvirit.ServerBuilder()
.ai("BLT:X", 3.14) # built-in store, order 0
.add_source("fast", 10, FastCache())
.add_source("fallback", 100, Fallback())
.build()
)
So -10 shadows the built-in store, and 10 is a fallback for names it
does not know. This is the whole resolution model: first claim wins.
Claiming by naming convention
A source can serve PVs that were never declared. This one owns everything
starting with XYZ: and creates each PV on first touch:
#![allow(unused)] fn main() { fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> { let name = name.to_string(); Box::pin(async move { if !self.matches(&name) { return None; } // Every wildcard PV is writable and uses F64 Some(PvInfo { descriptor: f64_scalar_desc(), writable: true, }) }) } }
The Python equivalent, claiming SCRATCH: instead — same three methods,
plus the notifier that publishes each PUT to subscribers:
class WildcardSource:
"""Accept any PV under PREFIX and keep a per-name float value."""
def __init__(self) -> None:
self._values: dict[str, float] = {}
self._notifier: spvirit.Notifier | None = None
self._lock = threading.Lock()
# Called by the server right after build() — stash the notifier.
def on_start(self, notifier: spvirit.Notifier) -> None:
self._notifier = notifier
def claim(self, name: str):
if not name.startswith(PREFIX):
return None
return spvirit.PvInfo.nt_scalar("double", writable=True)
def get(self, name: str):
if not name.startswith(PREFIX):
return None
with self._lock:
val = self._values.setdefault(name, 0.0)
return spvirit.NtScalar(val)
def put(self, name: str, value):
"""value is a Python dict/value built from the PUT payload."""
if not name.startswith(PREFIX):
return None
new_val = _coerce_float(value)
with self._lock:
self._values[name] = new_val
# Publish the update to PVA monitor subscribers.
if self._notifier is not None:
self._notifier.notify(name, spvirit.NtScalar(new_val))
# Return propagation list (only this PV changed).
return spvirit.NtScalar(new_val)
def names(self):
# Report only the names we've seen so far. A dynamic namespace
# cannot enumerate what does not exist yet; the PVs still serve.
with self._lock:
return list(self._values.keys())
$ spget XYZ:NEW
XYZ:NEW 0 # sprang into existence on the search
$ spput XYZ:NEW 42 && spget XYZ:NEW
XYZ:NEW 42
$ spget ABC:NEW
Error: Timeout("search response") # nothing claims ABC:
Note the failure mode for an unclaimed name: a search timeout, not a "not found" error. Nothing answers the UDP search, so the client waits.
Backing PVs with a file
#![allow(unused)] fn main() { impl Source for JsonSource { fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> { let name = name.to_string(); Box::pin(async move { if self.pvs.read().await.contains_key(&name) { Some(PvInfo { descriptor: f64_desc(), writable: true, }) } else { None } }) } fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> { let name = name.to_string(); Box::pin(async move { let pvs = self.pvs.read().await; let val = *pvs.get(&name)?; Some(NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64( val, )))) }) } fn put( &self, name: &str, value: &DecodedValue, ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> { let name = name.to_string(); let value = value.clone(); Box::pin(async move { let new_val = match &value { DecodedValue::Float64(v) => *v, DecodedValue::Int32(v) => *v as f64, DecodedValue::Structure(fields) => fields .iter() .find(|(k, _)| k == "value") .and_then(|(_, v)| match v { DecodedValue::Float64(f) => Some(*f), DecodedValue::Int32(i) => Some(*i as f64), _ => None, }) .ok_or("missing numeric 'value' field")?, _ => return Err("unsupported value type".to_string()), }; { let mut pvs = self.pvs.write().await; if !pvs.contains_key(&name) { return Err(format!("PV '{}' not found in JSON store", name)); } pvs.insert(name.clone(), new_val); } // Persist to disk after every write self.persist().await; println!("[json_source] persisted {} = {}", name, new_val); let payload = NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(new_val))); Ok(vec![(name, payload)]) }) } }
Values survive a restart because put writes the JSON file synchronously
and the constructor reads it back:
$ spput JSON:SETPOINT_A 123.4
JSON:SETPOINT_A OK
# ... stop the server, start it again ...
$ spget JSON:SETPOINT_A
JSON:SETPOINT_A 123.4
Pushing monitor updates
A source with a value that changes on its own — a polled instrument, a
subscription to some other system — needs to push. In Rust that is
subscribe, returning a channel the server drains. In Python subscribe is
ignored; you keep the Notifier from on_start and call it from whatever
thread produces the data:
# A Python source's `subscribe` is ignored: monitor events come from the
# notifier the server hands to `on_start`. Nothing polls `get` on your
# behalf, so a source that never notifies looks frozen to a subscriber.
def _loop(self):
t = 0.0
while not self._stop.is_set():
new = {
"SENSOR:TEMP": 22.0 + 2.0 * math.sin(t),
"SENSOR:PRESSURE": 1.0 + 0.1 * math.sin(t * 1.7),
"SENSOR:FLOW": 5.0 + 1.0 * math.sin(t * 0.4),
}
with self._lock:
self._values.update(new)
# Publish updates — this is what delivers monitor events to clients.
if self._notifier is not None:
for name, v in new.items():
self._notifier.notify(name, spvirit.NtScalar(v))
t += 0.2
time.sleep(0.5)
RPC
rpc is the one trait method with a default implementation — it returns
Err("RPC not supported"), so a source only opts in by overriding it:
#![allow(unused)] fn main() { fn rpc( &self, name: &str, args: &DecodedValue, ) -> Pin<Box<dyn Future<Output = Result<NtPayload, String>> + Send + '_>> { let name = name.to_string(); let args = args.clone(); Box::pin(async move { if name != "RPC:add" { return Err(format!("unknown RPC channel '{}'", name)); } let a = extract_f64(&args, "a").unwrap_or(0.0); let b = extract_f64(&args, "b").unwrap_or(0.0); let sum = a + b; println!("[rpc] {a} + {b} = {sum}"); Ok(NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64( sum, )))) }) } }
In Python it is likewise optional — a source without an rpc method simply
has no RPC channel:
# `rpc` is optional: a source that does not define it simply has no RPC.
def rpc(self, name: str, args):
if name != self.CHANNEL:
raise RuntimeError(f"unknown RPC channel: {name}")
# `args` is a Python dict built from the decoded request structure.
a = _as_float(args.get("a", 0.0))
b = _as_float(args.get("b", 0.0))
return spvirit.NtScalar(a + b)
spvirit ships no general-purpose RPC client. Neither spvirit-client
nor any of the CLI tools can call an arbitrary RPC channel — the only RPC
in the client is an internal path used by pvlist. To exercise an RPC
source, use p4p or pvxs:
from p4p.client.thread import Context
ctx = Context('pva')
print(ctx.rpc('RPC:add', {'a': 3.0, 'b': 4.0})) # 7.0
Other shapes in the repo
| Example | Pattern |
|---|---|
passthrough_source.rs | decorator — wraps another source to add logging, access control, rate limiting |
aggregate_source.rs | derived PVs computed from the built-in store's values |
custom_pvstore.rs | replacing the store wholesale rather than layering on it |
mailbox.rs | minimal writable scratch PVs |
The Python family is demo_source_*.py — sensor, async, multi,
passthrough, aggregate, rpc, wildcard. demo_source_async.py is the
one with no Rust counterpart here: it shows a source whose get is an
async def, which the adapter awaits on the server's runtime.
What to notice
claim is on the hot path. It is called for every channel search from
every client on the network. Keep it cheap — no I/O, no locks held across
awaits. Do the expensive work in get.
names() drives splist. A source that returns an empty names()
still serves its PVs; they just do not show up in listings. The wildcard
source cannot enumerate what does not exist yet, which is the honest
answer for a dynamic namespace.
Sources bypass the record layer entirely. No MDEL, no alarm
computation, no scan, no .FIELD access — those are properties of
RecordInstance, and a source does not have one. If you want deadbands,
implement them in subscribe.
Returning Some from claim is a commitment. There is no way to
un-claim afterwards; a subsequent get returning None surfaces to the
client as an error rather than falling through to the next source.
Run it
cargo run -p spvirit-server --example multi_source
cargo run -p spvirit-server --example wildcard_source
cargo run -p spvirit-server --example json_source
cargo run -p spvirit-server --example rpc_source
python spvirit-py/examples/demo_source_multi.py
python spvirit-py/examples/demo_source_wildcard.py
python spvirit-py/examples/demo_source_sensor.py
python spvirit-py/examples/demo_source_rpc.py
Each is a server; drive it from a second terminal. multi_source registers
several sources on one server, and splist shows them merged into one flat
namespace — nothing in the listing says which source owns which PV:
$ splist 127.0.0.1:5075
COMPUTED:TIME
CONST:E
CONST:PI
SIM:COUNTER
__pvlist
$ spget CONST:PI
CONST:PI 2026-08-06 09:19:57.058 3.141593
$ spget COMPUTED:TIME
COMPUTED:TIME 2026-08-06 09:19:57.139 1786007997.139291
$ spget SIM:COUNTER
SIM:COUNTER 2026-08-06 09:19:56.859 3
wildcard_source claims a whole prefix, so the PV does not exist until you
write to it:
$ spput XYZ:MyValue 42.0
XYZ:MyValue OK
$ spget XYZ:MyValue
XYZ:MyValue 42
$ spput XYZ:sensor/temp 21.5
XYZ:sensor/temp OK
$ splist 127.0.0.1:5075
STATIC:HEARTBEAT
XYZ:MyValue
XYZ:sensor/temp
__pvlist
Two things to notice. spget XYZ:MyValue prints no timestamp — the
source returns a bare value and nothing stamped it, unlike a record. And
splist only reports the names created so far; a wildcard source cannot
enumerate an infinite namespace.
json_source writes through to disk, so the value survives a restart:
$ spput JSON:SETPOINT_A 123.4
JSON:SETPOINT_A OK
$ spget JSON:SETPOINT_A
JSON:SETPOINT_A 123.4
# stop the server with Ctrl-C, start it again
$ spget JSON:SETPOINT_A
JSON:SETPOINT_A 123.4
The server prints its side of that on startup:
[json_source] loaded 4 PVs from pvstore.json
JSON file-backed source server running on port 5075
Persistent PVs: JSON:SETPOINT_A, JSON:SETPOINT_B, JSON:LIMIT_HI, JSON:LIMIT_LO
In-memory PV: SIM:HEARTBEAT
Storage file: pvstore.json
It creates pvstore.json in your working directory — delete it if you want
to start from the defaults again.
rpc_source has no expected output here, because as noted above spvirit
ships no RPC client; use p4p or pvcall from pvxs against it.
Next
A complete IOC
✅ Verified ·
complete_ioc.rs·demo_complete_ioc.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
One server that uses everything in Part III at once: a scanned readback with units and a deadband, a validated setpoint, a derived PV, an array, and an explicitly-managed alarm. It is small, but it is shaped like a real piece of equipment rather than a demo.
The device is a vacuum system:
| PV | Record | Role |
|---|---|---|
VAC:PRESSURE | ai | scanned readback, pumping down |
VAC:SETPOINT | ao | target pressure, range-checked on write |
VAC:ERROR | calc | readback minus setpoint |
VAC:RGA | aai | a 64-point residual-gas spectrum |
VAC:LINK | ai | controller reachability, severity set by hand |
Rust
#![allow(unused)] fn main() { // --- Readback: scanned, with units and a monitor deadband ----------- let tick = Arc::new(AtomicU64::new(0)); let t = tick.clone(); let pressure = Pv::ai("VAC:PRESSURE", 1.0e-6) .units("mbar") .prec(3) .desc("Chamber pressure") .mdel(1.0e-8) // suppress sub-nanobar jitter .scan(Duration::from_millis(500), move |_pv| { let n = t.fetch_add(1, Ordering::Relaxed) as f64; // A decaying pump-down curve with a little noise. 1.0e-6 * (-n / 40.0).exp() + 1.0e-9 * (n * 1.7).sin() }); // --- Setpoint: validated on write ----------------------------------- let setpoint = Pv::ao("VAC:SETPOINT", 1.0e-6) .units("mbar") .prec(3) .desc("Target pressure") .on_put(|pv, value: f64| { // Drive limits are advisory, so enforce the range here. if !(1.0e-9..=1.0e-3).contains(&value) { return Err(format!("{}: {value} outside 1e-9..1e-3", pv.name())); } println!("{} -> {value:e}", pv.name()); Ok(()) }); // --- Derived: recomputed whenever an input moves --------------------- let error = Pv::calc("VAC:ERROR", &[&pressure, &setpoint], |inputs: &[f64]| { inputs[0] - inputs[1] }) .units("mbar") .desc("Readback minus setpoint"); // --- Array: a spectrum a client can read but not write --------------- let spectrum = PvArray::aai("VAC:RGA", ScalarArrayValue::F64(vec![0.0; 64])); // --- Status: severity we set ourselves ------------------------------- let status = Pv::ai("VAC:LINK", 0.0).desc("Gauge controller link"); let server = PvaServer::serve([ pressure.clone(), setpoint.clone(), error.clone(), status.clone(), ]) .pvs([spectrum.clone()]) .build() .await; }
Everything above is declarative — you describe the records and hand them to the server. Anything the IOC needs to do beyond that is an ordinary Tokio task holding the same handles:
#![allow(unused)] fn main() { // Everything above is declarative. Anything else you want the IOC to do // is an ordinary task driving the handles. let spec = spectrum.clone(); tokio::spawn(async move { let mut frame = 0u64; loop { let data: Vec<f64> = (0..64) .map(|i| ((i as f64) * 0.2 + frame as f64 * 0.1).sin().abs()) .collect(); let _ = spec.set(ScalarArrayValue::F64(data)).await; frame += 1; tokio::time::sleep(std::time::Duration::from_millis(200)).await; } }); // The gauge controller is reachable, so clear the alarm explicitly. status.set_alarm(0, 0, "").await?; }
Note the two-call construction. PvaServer::serve() takes one homogeneous
iterator, so the four Pv<f64> handles go in the first call and the
PvArray goes in through .pvs()
(spvirit-server/src/pva_server.rs:724, :741). Chain .pvs() as many
times as you have distinct handle types.
Python
The same five records, with three differences worth naming:
# --- Readback: scanned, with units and a monitor deadband ---------------
pressure = spvirit.ai(
"VAC:PRESSURE",
1.0e-6,
units="mbar",
prec=3,
desc="Chamber pressure",
mdel=1.0e-8, # suppress sub-nanobar jitter
)
_tick = 0
@pressure.scan(period=0.5)
def _pump_down(pv):
global _tick
n = float(_tick)
_tick += 1
# A decaying pump-down curve with a little noise.
return 1.0e-6 * math.exp(-n / 40.0) + 1.0e-9 * math.sin(n * 1.7)
# --- Setpoint: validated on write ---------------------------------------
setpoint = spvirit.ao(
"VAC:SETPOINT", 1.0e-6, units="mbar", prec=3, desc="Target pressure"
)
@setpoint.on_put
def _check_range(pv, value):
# Drive limits are advisory, so enforce the range here. Raising rejects
# the PUT and sends the exception text back to the client; returning
# False also rejects, but with a fixed "rejected by on_put" message.
if not 1.0e-9 <= value <= 1.0e-3:
raise ValueError(f"{pv.name}: {value} outside 1e-9..1e-3")
print(f"{pv.name} -> {value:e}")
# --- Derived: recomputed whenever an input moves -------------------------
# `calc` takes handles, not names, and no metadata keywords — units and
# desc are not settable on a computed PV from Python.
error = spvirit.calc("VAC:ERROR", [pressure, setpoint], lambda vals: vals[0] - vals[1])
# --- Array: a spectrum a client can read but not write -------------------
spectrum = spvirit.aai("VAC:RGA", [0.0] * 64)
# --- Status: severity we set ourselves -----------------------------------
status = spvirit.ai("VAC:LINK", 0.0, desc="Gauge controller link")
# One flat list, whatever the handle types. Callbacks must already be
# attached at this point: the handles are bound here, and `on_put`/`scan`/
# `calc` are ignored on a bound handle.
server = spvirit.Server(pvs=[pressure, setpoint, error, spectrum, status])
server.start()
Server(pvs=[...]) takes one flat list — the two-call split above is a
Rust type-system constraint, not a protocol one, so the array handle goes
in beside the scalars.
spvirit.calc(name, inputs, callback) accepts no metadata keywords, so
VAC:ERROR cannot carry units="mbar" or a description the way the Rust
version does. If a computed PV needs metadata, write it through the raw-NT
layer with store.put_nt(...) after the server is up
(Records vs raw NT).
Array PVs reject the scalar keywords (units, prec, desc, adel,
mdel, and both limit pairs) with TypeError, and on_put/scan raise
TypeError on them too (spvirit-py/src/pv.rs:307, :365). VAC:RGA is
therefore driven from a plain loop rather than a scan callback:
# Everything above is declarative. Anything else you want the IOC to do is
# an ordinary loop driving the handles. `scan` is not available on an array
# PV, so VAC:RGA is updated from here.
# The gauge controller is reachable, so clear the alarm explicitly.
status.set_alarm(0, 0, "")
frame = 0
while True:
spectrum.set([abs(math.sin(i * 0.2 + frame * 0.1)) for i in range(64)])
frame += 1
time.sleep(0.2)
Note the rejection path. Raising from on_put sends the exception's text
to the client, matching the Rust Err(String); returning False also
rejects but the client sees a fixed rejected by on_put
(spvirit-py/src/pv.rs:559).
Run it
# Terminal 1
cargo run -p spvirit-server --example complete_ioc
# or: python spvirit-py/examples/demo_complete_ioc.py
Discover the server, then ask it for its PV list:
$ splist
GUID 0xC4960000E061B581C193C818 version 2: tcp@[ 10.64.23.134:5075 ]
$ splist 127.0.0.1:5075
VAC:ERROR
VAC:LINK
VAC:PRESSURE
VAC:RGA
VAC:SETPOINT
__pvlist
splist with no argument lists servers; splist <target> lists the PVs
on one. __pvlist is the server's own introspection channel — it is how
that second call works, and it appears in every listing.
Read and write:
$ spget VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:35:23.578 0.000001
$ spput VAC:SETPOINT 5e-4
VAC:SETPOINT OK
$ spget VAC:ERROR
VAC:ERROR 2026-08-04 10:35:56.070 -0.0005
$ spput VAC:SETPOINT 1.0
VAC:SETPOINT ERROR protocol error: PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3
The derived PV moved on its own: on_put accepted the setpoint, the store
propagated the change through the link graph, and VAC:ERROR recomputed
before the next spget arrived.
What to notice
A client PUT always advances the record's timeStamp. The write
applies and the record is restamped with server time, distinct from
whatever it carried before:
$ spput VAC:SETPOINT 2e-4 && spget VAC:SETPOINT
VAC:SETPOINT 2026-08-04 10:35:11.566 0.0002
$ spput VAC:SETPOINT 3e-4 && spget VAC:SETPOINT
VAC:SETPOINT 2026-08-04 10:35:57.902 0.0003
Each read carries the time of its own PUT, not the record's creation
time. RecordInstance::apply_put
(spvirit-server/src/apply.rs:546) updates value, alarm, display
and control from the client's structure and then always restamps —
with the client's timeStamp if the PUT carried a non-default one, so a
gateway can forward the originating acquisition time, otherwise with
server time — whether or not the value itself changed. That matches
EPICS Base, where recGblGetTimeStampSimm() runs unconditionally in
process(). Server-driven updates — scan, calc, set — stamp too,
which is why VAC:PRESSURE and VAC:ERROR above move on the same
rhythm as the client-written VAC:SETPOINT.
The deadband is doing its job. VAC:PRESSURE scans every 500 ms, but
a monitor posts roughly once a second:
$ spmonitor VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:36:09.069 0
VAC:PRESSURE 2026-08-04 10:36:10.065 0
.mdel(1.0e-8) suppresses every tick whose change is smaller than that.
Half the scans move less than a nanobar and are dropped. See
Monitors.
spget formatting is not the value. Once the chamber pumps below
about 1e-7 the display collapses to 0. The wire value is intact —
spget -F value and spmonitor read the same double. For a fixed number
of digits, use the record's PREC field and a client that honours it, or
read the raw field.
Pv::calc takes handles, not names. The signature is
calc(name, inputs: &[&Pv<f64>], f) (spvirit-server/src/pv.rs:392), so
the input PVs must exist as handles before the derived PV is built. That
ordering constraint is what makes the link graph resolvable at
construction time instead of at first read.
Validation belongs in on_put, not in DRVL/DRVH. The drive limits
are published for GUIs; nothing enforces them. The range check here is the
only thing keeping 1.0 out of the record — and because spput retries a
rejected write, keep the callback idempotent
(Reacting to writes).
Where to go next
You now have every building block. The remaining parts of this book are reference rather than tutorial:
- Command-line tools — the
sp*family in detail - Developer guide — internals, protocol notes, and the crate-level API
Command-line tools
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
spvirit-tools ships twelve binaries. They are named sp* rather than
pv* so they can sit alongside an EPICS Base installation without
shadowing pvget, pvput, and friends.
| Tool | Kind | What it does |
|---|---|---|
spget | client | read a PV once |
spput | client | write a PV |
spmonitor | client | subscribe to changes |
spinfo | client | print a PV's type structure |
splist | client | discover servers and their PVs |
spexplore | client, TUI | browse a server interactively |
spsearch | client, TUI | passively watch PVA search traffic |
spsine | client | drive a PV with a sine wave |
spget_compare | offline | replay a captured GET frame |
spserver | server | serve a .db file |
sptable | server, TUI | an interactive spreadsheet IOC |
spdodeca | server | serve a rotating wireframe as an image |
Building them
The binaries are gated behind Cargo features, so a plain
cargo build -p spvirit-tools produces none of them:
cargo build -p spvirit-tools --all-features
| Feature | Tools it enables |
|---|---|
client | spget, spput, spmonitor, spinfo, splist, spsine, spget_compare |
server | spserver, spdodeca |
client + tui | spexplore, spsearch |
server + tui | sptable |
Flags every client shares
The seven client tools take the same connection options, because they
share one argument-parser helper. Rather than repeat the table on each
page, it is here:
| Flag | Meaning |
|---|---|
-w, --timeout | timeout in seconds |
--server | talk to ip:port directly, skipping search |
--search-addr | search target IP; defaults to EPICS_PVA_ADDR_LIST or broadcast |
--bind-addr | local IP to bind the search socket to |
--name-server | PVA name server host:port; repeatable via EPICS_PVA_NAME_SERVERS |
--udp-port | search port (default 5076) |
--tcp-port | default server port (default 5075) |
--no-broadcast | disable UDP broadcast/multicast search; same as EPICS_PVA_AUTO_ADDR_LIST=NO |
--authnz-user | override the AuthNZ user sent at connect |
--authnz-host | override the AuthNZ host sent at connect |
-F, --fields | comma-separated dotted field paths to request; empty means all |
-d, --debug | verbose protocol logging |
spsearch is the exception — it never opens a TCP channel, so it takes
only --udp-port, --bind-addr, and --debug.
Environment variables
The standard EPICS PVAccess variables are honoured:
EPICS_PVA_ADDR_LIST, EPICS_PVA_AUTO_ADDR_LIST,
EPICS_PVA_NAME_SERVERS. Explicit flags win over the environment.
A server to try them against
Every page below assumes something is serving. The quickest option:
cargo run -p spvirit-server --example complete_ioc
That is the capstone IOC, which publishes a scalar, a setpoint, a derived PV, and an array.
spget
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Read one PV, print it, exit. The equivalent of EPICS Base pvget.
spget [OPTIONS] [PV]
Requires the client feature. Takes exactly one PV — pass several names
and it errors with Unexpected argument. Loop in the shell if you need
more than one.
Flags
The shared client flags apply (see the index).
spget adds none of its own.
-F/--fields is the one worth knowing: it narrows the field request
sent to the server, so -F value fetches the value without the alarm,
timestamp, and display blocks.
Output
$ spget VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:35:23.578 0.000001
Three columns: name, the record's timeStamp, and the value. An alarm
appends severity and status:
$ spget DEMO:SETPOINT
DEMO:SETPOINT 2026-08-06 09:14:58.052 46 MAJOR READ HIHI
Structured payloads print inline:
$ spget SIM:STATE
SIM:STATE 2026-08-06 09:14:32.958 {index=0, choices=["Idle", "Running", "Error"]}
$ spget VAC:RGA
VAC:RGA 2026-08-06 09:35:20.331 [0.909297, 0.808496, 0.675463, ...]
The array above is elided for the page; spget prints every element,
however many there are. A 1024-point waveform is one very long line.
Gotchas
The printed value is formatted, not raw. Small doubles collapse:
$ spget VAC:SETPOINT # the record holds 5e-7
VAC:SETPOINT 2026-08-06 09:35:33.545 0
The wire value is intact — but neither -F value nor --json will show it
to you, because both go through the same formatter:
$ spget -F value VAC:SETPOINT
VAC:SETPOINT 0
$ spget --json VAC:SETPOINT
{"alarm":"alarm=OK status=NO_ALARM(0)","pv":"VAC:SETPOINT",
"timestamp":"2026-08-06 09:35:33.545","units":null,
"value":"value=0.000000, ts=1786008933"}
spget --raw dumps the payload bytes, which does contain the true double;
otherwise read the PV from a client library rather than a CLI.
Search failure looks like a timeout. If nothing answers, you get
Timeout("search response") — not "PV not found". No server on the
network can distinguish the two, so neither can the client. Check the PV
name, then splist, then --server.
See also
Reading and writing does the same thing from the Rust and Python APIs.
spput
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Write a PV. The equivalent of EPICS Base pvput.
spput [OPTIONS] [PV] [VALUE]
Requires the client feature.
Three ways to give a value
spput VAC:SETPOINT 5e-4 # positional
spput VAC:SETPOINT=5e-4 # PV=VALUE
spput SIM:MODE --json '{"value":{"index":2}}'
Use the PV=VALUE form for negative numbers. A positional value cannot
start with -, because the parser reads it as a flag:
spput COUNTER=-1 # works
spput COUNTER -1 # does not
--json takes a full payload structure and is how you write anything that
is not a bare scalar.
Flags beyond the shared set
| Flag | Meaning |
|---|---|
--json JSON | JSON payload to write |
--simple-flow | init + write only; skip the pre/post GET, the get-put probe, and DESTROY_REQUEST |
--no-flow-fallback | do not retry with the simple flow when the full flow fails |
By default spput performs the full EPICS-Base-style PUT flow: a GET
before the write, a get-put capability probe, the write, a GET after, then
an explicit destroy. That is what a real pvput does, and it is what
exercises the same code paths in a server under test.
Output
$ spput VAC:SETPOINT 5e-4
VAC:SETPOINT OK
$ spput VAC:SETPOINT 1.0
VAC:SETPOINT ERROR protocol error: PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3
Error: Protocol("PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3")
The exit status is non-zero on failure, so spput ... && spget ... is
safe in a script.
Gotchas
A rejected write reaches the server twice. When the full flow fails,
spput silently falls back to the simple flow
(spvirit-tools/src/bin/spvirit_put.rs:214). A server-side on_put
callback therefore fires once for an accepted write and twice for a
rejected one. Pass --no-flow-fallback to suppress the retry, and write
on_put callbacks to be idempotent either way — see
Reacting to writes.
Read-only records refuse explicitly. ai, bi, stringin and aai
answer with PUT init error: Write access denied rather than accepting
and discarding.
Enum writes are accepted and dropped. spput SIM:MODE --json '{"value":{"index":2}}' prints OK and changes nothing. This is a server
bug, documented in Enums.
spmonitor
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Subscribe to one or more PVs and print every update until interrupted.
spmonitor [OPTIONS] [PV ...]
Requires the client feature. Unlike spget, this one does take
several PV names.
Flags beyond the shared set
| Flag | Meaning |
|---|---|
--raw | print the raw hex payload |
--json | print JSON instead of the default line format |
--pipeline N | enable monitor pipelining with queue size N (0 = off) |
Pipelining lets the server send ahead without waiting for an acknowledgement per update. It matters for high-rate PVs on a slow link; leave it off otherwise.
Output
$ spmonitor VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:36:09.069 0
VAC:PRESSURE 2026-08-04 10:36:10.065 0
Same three columns as spget, one line per posted update. Ctrl-C to stop.
Gotchas
You see posts, not writes. A record whose MDEL deadband swallows a
change posts nothing, so a monitor can look frozen while the value moves.
The capstone IOC scans at 2 Hz and posts at about 1 Hz for exactly this
reason. See Monitors.
Alarm transitions always post, regardless of the deadband
(spvirit-server/src/simple_store.rs:545). A severity change is never
suppressed.
The first update is the current value. A subscription delivers one immediate post at connect, then only changes. Do not treat the first line as an event.
See also
Monitoring changes for the library APIs behind this tool.
spinfo
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Print a PV's type structure without fetching its value. The equivalent of
EPICS Base pvinfo.
spinfo [OPTIONS] [PV]
Requires the client feature. Uses the CMD_GET_FIELD (0x11) protocol
command, so the server answers with an introspection description and no
data.
Flags beyond the shared set
| Flag | Meaning |
|---|---|
-f, --field PATH | inspect a sub-field, e.g. value or alarm.severity |
-t, --terse | one-line type summary instead of the tree |
Output
$ spinfo VAC:SETPOINT
VAC:SETPOINT:
struct epics:nt/NTScalar:1.0
value: double
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
display: structure
limitLow: double
limitHigh: double
description: string
units: string
precision: int
form: structure
index: int
choices: string[]
control: structure
limitLow: double
limitHigh: double
minStep: double
valueAlarm: structure
active: boolean
lowAlarmLimit: double
lowWarningLimit: double
highWarningLimit: double
highAlarmLimit: double
What it is good for
Confirming the normative type. The first line is the type ID. If a
client library refuses a PV, this tells you whether the server is really
sending epics:nt/NTScalar:1.0 or something else.
Seeing metadata that is published but unused. The valueAlarm block
above exists because the record was built with .alarm_limits(...). The
limits are on the wire, and spinfo proves it — but the server never
compares the value against them. That distinction is the subject of
Alarms, and spinfo is how you tell the
two cases apart.
Working out what -F can ask for. The dotted paths in spget -F and
spmonitor -F are exactly the paths in this tree.
splist
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Discover PVA servers, and list the PVs on one. The equivalent of EPICS
Base pvlist.
splist [OPTIONS] [TARGET]
Requires the client feature. TARGET may be ip:port, a bare ip, or
a GUID beginning with 0x.
Two modes
No argument — find servers:
$ splist
GUID 0xC4960000E061B581C193C818 version 2: tcp@[ 10.64.23.134:5075 ]
With a target — list its PVs:
$ splist 127.0.0.1:5075
VAC:ERROR
VAC:LINK
VAC:PRESSURE
VAC:RGA
VAC:SETPOINT
__pvlist
The GUID from the first form works as the target of the second, which is useful when a server advertises an address you cannot route to directly.
__pvlist
That last entry is not one of your PVs. It is the server's introspection
channel — the mechanism the second form uses. Every spvirit server exposes
it unless started with --pvlist-mode off, and it appears in every
listing.
Gotchas
Listing is opt-in on the server side, discovery is not. With
--pvlist-mode discover or off, the server still answers a broadcast
search — splist with no argument finds it — but refuses to enumerate:
$ splist 127.0.0.1:5075
Error: Protocol("failed to list PVs from 127.0.0.1:5075: ... __pvlist:
create_channel error: code=1 message=PV not found; GET_FIELD: disabled;
RPC(server): ... RPC list endpoint disabled (set --pvlist-mode=list) ...")
The error is long because splist tries four routes in turn — the
__pvlist channel, GET_FIELD, an RPC endpoint, and a server GET — and
reports every one that failed. "RPC list endpoint disabled" is the line
that tells you it is a policy decision, not a broken server. Normal reads
still work throughout. --pvlist-max and --pvlist-allow-pattern expose
only part of a database. See spserver.
Discovery is a UDP broadcast. On a host with several interfaces the
search may leave by the wrong one. --search-addr or
EPICS_PVA_ADDR_LIST pins it; --server skips discovery entirely.
spexplore
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
A three-pane terminal browser for a PVA network: servers on the left, that server's PVs in the middle, the selected PV's live value and structure on the right.
spexplore [OPTIONS]
Requires the client and tui features. Takes no PV argument — you
discover everything from inside.
Flags beyond the shared set
| Flag | Meaning |
|---|---|
--poll-interval SECS | how often to refresh the selected PV |
Workflow
- Press
rto discover servers. - Select a server in the left pane and press Enter.
- Select a PV in the middle pane and press Enter.
- Watch streaming value and structure updates on the right.
Keys
| Key | Action |
|---|---|
q | quit |
h | toggle the help modal |
Tab | cycle focus between the three panes |
↑ / ↓ | navigate the focused pane |
Enter | activate the selection |
f | type a PV filter (Enter applies) |
a | add a PV by name (Enter applies) |
t | toggle between the text and chart views |
r | refresh discovery, list, or monitor |
p | pause / resume the monitor |
x or Esc | cancel in-flight operations |
The chart view (t) draws the selected scalar as a sparkline over the
last 240 samples.
Gotchas
a exists because listing can be refused. A server started with
--pvlist-mode discover or off shows up in the left pane with an empty
PV list. Press a, type the name, and it monitors normally — enumeration
and access are separate permissions. See splist.
Discovery is manual. Nothing happens until you press r; the status
line says so on startup. This keeps the tool quiet on a busy network.
Every pane operation is cancellable. A slow or unreachable server
blocks nothing — x drops the in-flight request and returns the UI.
spsearch
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
A passive network monitor. It listens on the PVA UDP search multicast group and shows every PV name anyone on the network is asking for, and which servers answered.
spsearch [OPTIONS]
Requires the client and tui features.
Flags
spsearch does not take the shared client options — it never opens a
TCP channel, so most of them are meaningless. It has three:
| Flag | Default | Meaning |
|---|---|---|
-p, --udp-port PORT | 5076 | UDP search port to listen on |
-b, --bind-addr IP | — | local IP for the listener |
-d, --debug | off | verbose logging |
Keys
| Key | Action |
|---|---|
q | quit |
h | toggle help |
Tab | cycle focus between the table and the detail panel |
↑ / ↓, PgUp / PgDn | navigate |
/ | filter PV names (Enter applies, Esc cancels) |
s | cycle sort mode |
p | pause / resume updates |
c | clear stale entries (older than 5 minutes) |
Green rows are PVs that at least one server has answered for. The detail panel shows who searched and who responded.
What it is for
Finding the client nobody remembers deploying. A PV name appearing in the search table with no green means something is looking for a record that does not exist. The detail panel names the source address.
Confirming a server is answering. Watch the row turn green while
running spget from another terminal — that is the search-response leg of
the protocol, live.
Diagnosing a multi-interface host. If searches never appear, the
listener is on the wrong NIC; pin it with --bind-addr.
Gotchas
It only sees broadcast and multicast traffic. A client using
--server or EPICS_PVA_NAME_SERVERS connects straight to TCP and never
searches, so nothing shows up. Absence in spsearch does not mean absence
on the network.
It shows requests, not the ones you make yourself with --server. The
same caveat, from the other side: to see your own traffic, let it search.
spsine
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Drive a PV with a sine wave by writing to it at a fixed rate. A load generator and a "make something move" button, not part of any IOC.
spsine [OPTIONS] [PV]
Requires the client feature. It is a client — the PV must already
exist on a server, and must be writable.
Flags beyond the shared set
| Flag | Default | Meaning |
|---|---|---|
--freq HZ | — | sine frequency |
--rate N | — | writes per second |
--amp A | — | amplitude |
--offset O | — | vertical offset |
--phase RAD | — | phase in radians |
--duration SECS | 0 | run time; 0 means forever |
The value written is offset + amp * sin(2π · freq · t + phase).
Running it
$ spsine DEMO:SETPOINT --freq 0.5 --rate 2 --amp 10 --offset 20 --duration 3
$ spget DEMO:SETPOINT
DEMO:SETPOINT 2026-08-04 10:40:21.801 29.993539
It prints nothing. Silence is success — watch the PV with
spmonitor in another terminal if you want to see it
move.
Gotchas
--rate is the write rate, --freq is the waveform. Set the rate to
at least a few times the frequency or you will sample the sine into
something that does not look like one.
Each write is a full PUT. spsine uses the same client path as
spput, so a server-side on_put callback fires on every sample. At
--rate 100 that is a hundred callbacks a second — which is precisely
what makes this useful for load testing, and a nuisance if you forgot.
It writes; it does not serve. If nothing answers the search you get a
timeout. To generate a moving PV rather than drive an existing one, use
a scanned record (Simulating a device)
or sptable's :anim command.
spget_compare
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Replay a captured PVAccess GET exchange and check spvirit's encoders against it byte for byte. A protocol-debugging tool, not something you reach for day to day.
spget_compare [OPTIONS]
Requires the client feature. It talks to no network — it reads a file.
Flags
| Flag | Meaning |
|---|---|
--dump-file PATH | hex dump, human-readable |
--dump-raw PATH | binary dump, u32 little-endian length prefix per frame |
One of the two is required:
$ spget_compare
Provide --dump-raw or --dump-file
The hex dump format
A --dump-file is what you get from copying frames out of a packet
capture. Direction markers separate frames; blank lines end them; an
optional four-character offset column is stripped:
C->S
0000 ca 02 00 00 08 00 00 00 01 00 00 00 ...
0010 00 00 00 00
S->C
0000 ca 02 40 01 ...
Anything that is not a two-character hex byte is ignored, so annotated
captures usually paste in unchanged
(spvirit-tools/src/bin/spvirit_get_compare.rs:265).
--dump-raw has no direction markers. The tool infers direction from the
server bit in each frame's command flags
(spvirit-tools/src/bin/spvirit_get_compare.rs:256).
What it checks
It picks the first connection-validation, create-channel, GET init, and
GET data frame out of the capture, re-encodes each with spvirit's own
encoder, and compares. One line per frame, in one of two shapes
(spvirit-tools/src/bin/spvirit_get_compare.rs:160):
<LABEL>: OK (len=<n>)
<LABEL>: MISMATCH at offset <i> (actual len=<n>, expected len=<m>)
actual: <hh> expected: <hh>
The offset of the first differing byte is usually enough to identify the
field. This is how a wire-compatibility bug against EPICS Base gets
localised: capture the exchange from a working pvget, replay it here,
and read off where spvirit diverges.
spserver
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Serve an EPICS database file over PVAccess. No code, no build step — the
soft-IOC equivalent of softIoc -d my.db.
spserver [OPTIONS]
Requires the server feature.
Flags
| Flag | Default | Meaning |
|---|---|---|
--db-file PATH | — | EPICS .db file to load |
--listen-addr ADDR | 0.0.0.0 | address to bind |
--tcp-port PORT | 5075 | PVA TCP port |
--udp-port PORT | 5076 | PVA search port |
--reload-interval SECS | 2 | how often to re-read the .db file |
--advertise-addr ADDR | — | address to put in search responses |
--beacon-period SECS | — | beacon interval |
--beacon-addr IP:PORT | — | beacon target |
--conn-timeout SECS | — | idle connection timeout |
--compute-alarms | off | derive severity from LOW/HIGH/LOLO/HIHI |
--pvlist-mode MODE | list | off, discover, or list |
--pvlist-max N | 1024 | cap on names returned by a listing |
--pvlist-allow-pattern RE | — | regex filter on names exposed by a listing |
--debug | off | verbose protocol logging |
Running it
$ spserver --db-file spvirit-server/examples/example.db --compute-alarms
INFO spserver: Loaded DB file 'spvirit-server/examples/example.db' with 4 PVs
INFO spserver: Starting PVA server: udp=0.0.0.0:5076 tcp=0.0.0.0:5075
reload=2s pvlist_mode=List pvlist_max=1024 filter=<none>
Every connection and operation is logged:
INFO spserver: TCP connection 1 from 127.0.0.1:52125
INFO spserver: Conn 1: channel 'DEMO:SETPOINT' cid=1 sid=2
INFO spserver: Conn 1: put init pv='DEMO:SETPOINT' ioid=1
That startup line is worth reading. It tells you the effective ports and listing policy, which is faster than guessing when a client cannot find anything.
--compute-alarms
Off by default. With it on, LOW/HIGH produce MINOR and LOLO/HIHI
produce MAJOR on every write:
$ spput DEMO:SETPOINT 46 && spget DEMO:SETPOINT
DEMO:SETPOINT 46 MAJOR READ HIHI
This is currently the only route to computed alarms — limits set through
the Pv handle API are published but never evaluated. See
Alarms.
Reload
The .db file is re-read every --reload-interval seconds, so editing it
in place changes the served records without a restart. Set the interval
higher on a slow filesystem.
Listing policy
--pvlist-mode controls whether clients may enumerate:
| Mode | Search | splist <target> |
|---|---|---|
list (default) | answered | full list |
discover | answered | refused, "RPC list endpoint disabled" |
off | answered | refused |
Note that discover and off do not hide the server — a broadcast search
still finds it and named PVs still read and write. They only suppress
enumeration.
See also
Serving a .db file covers the database
syntax and which fields spvirit acts on.
sptable
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
An interactive spreadsheet that is an IOC. Each row is a PV; adding a
row serves it immediately. It is the fastest way to put a realistic set of
PVs on the network without writing a .db file or a line of code.
sptable [OPTIONS]
Requires the server and tui features.
| Flag | Default | Meaning |
|---|---|---|
--port PORT | 5075 | TCP port |
--udp-port PORT | 5076 | UDP search port |
--rate HZ | 10 | animation tick rate |
Two ways to drive it
Single keys operate on the selected row; : opens a command line for
everything else.
| Key | Action |
|---|---|
a | add a PV through a guided prompt |
e or Enter | edit the selected row's value |
d | delete the selected row |
j / k or ↑ / ↓ | move the selection |
: | open the command line |
? | scrollable help modal |
q or Esc | quit |
While you type a : command the footer shows a live hint for that verb —
and for :anim, once the generator name is present, it switches to that
generator's own parameters and defaults.
Commands
add|a <name> <type> [ro|rw] <value> add PV(s)
set|s <name> <value> set value (choice name or index for enum)
del|d [name] delete (blank = selected row)
rename|mv <old> <new> rename (scalar/enum)
ro|rw <name> set advertised access
anim <name> <gen> [k=v ...] animate
stop [name] stop animation (blank = selected)
source|so <file> run a script of commands
write|w <file> dump the session as a loadable script
rate <hz> retune the animation tick (live)
help|h quit|q
Types
bool int8 int16 int32(int) int64(long) uint8 uint16 uint32 uint64
float(f32) double(f64) string(s) ; arrays: int32[] ; enum ; table
Value forms for the compound types:
enum -> OFF,ON,TRIP 1
table -> id:i32=1,2,3 x:f64=0.5,1.5
Patterns
Any command that takes a name takes a bash-style brace pattern, and acts on every expansion:
{1..8} {8..1} {0..100..10} {01..12} {A,B,C}
Multiple braces form a cartesian product — S{1..4}:{A,B} is eight PVs.
Zero-padding is inferred from the width of the bounds, so {01..12}
produces 01, 02, … A pattern that would create more than 1000 PVs is
rejected rather than expanded
(spvirit-tools/src/bin/spvirit_table/pattern.rs:4) — a typo like
{1..1000000} fails instead of hanging the machine.
:add RING:BPM{01..99}:X f64 rw 0
Animation
:anim <name> <gen> [k=v ...] drives a row from a generator sampled at
the global tick rate.
| Generator | Parameters and defaults |
|---|---|
sine | amp=1 offset=0 period=10 phase=0 |
ramp | min=0 max=1 period=10 |
triangle | min=0 max=1 period=10 |
square | lo=0 hi=1 period=10 duty=0.5 |
noise | min=0 max=1 |
walk | start=0 step=1 min=0 max=1 |
count | start=0 step=1 |
cycle | period=1 (enum only) |
:anim RING:BPM{01..99} noise min=-1 max=1
:rate 50
:rate retunes every animation live — it is the tick rate, not a
per-generator frequency. Use period for the shape of one waveform.
Inapplicable parameters are an error, not a no-op. :anim X sine min=-2
is rejected, because sine's range comes from amp/offset and a silently
ignored min would look like it worked
(spvirit-tools/src/bin/spvirit_table/anim.rs:138).
Sessions
:write <file> dumps the whole session — rate, every row with its current
value and access, and every animation with its resolved parameters — as a
script of the same commands. :source <file> replays it. The dump
round-trips: what you write is what you can load.
# sptable session dump
rate 10
add RING:BPM01:X double rw 0.42
anim RING:BPM01:X noise min=-1 max=1
Animations are reconstructed from resolved parameters, so a session
written after :rate 50 and edited by hand still loads exactly.
Gotchas
:add skips names that already exist rather than overwriting, and
reports how many it skipped. Bulk-adding an overlapping pattern is safe.
A table row cannot be :set. The error is explicit — recreate it with
:add. Tables are a payload type, not a record with a value field; see
Tables and images.
Scalar values are coerced, not rejected. Setting 3.7 on an int32
row rounds and clamps to the type's range
(spvirit-tools/src/bin/spvirit_table/parse.rs:78). The row's type wins.
spdodeca
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Serves a rotating dodecahedron wireframe as an NtNdArray image PV. A
demo, and a genuinely useful one: it is a moving image on the network with
no camera, no driver, and no configuration.
spdodeca [OPTIONS]
Requires the server feature.
| Flag | Default | Meaning |
|---|---|---|
--pv NAME | DODECA:IMAGE | PV name to serve |
--width N | 256 | image width in pixels |
--height N | 256 | image height in pixels |
--rate HZ | 10 | frame update rate |
--tcp-port PORT | 5075 | TCP server port |
--udp-port PORT | 5076 | UDP search port |
--listen-addr ADDR | 0.0.0.0 | listen address |
--conn-timeout SECS | 60 | idle connection timeout |
--debug | off | verbose logging |
Running it
spdodeca --width 512 --height 512 --rate 25
Then point any NTNDArray-capable viewer at DODECA:IMAGE. spget will
confirm it is there, though it prints the pixels rather than the picture:
$ spget DODECA:IMAGE
DODECA:IMAGE 2026-08-04 10:46:46.086 {ubyteValue=[0, 0, 0, 0, 0, ...]}
ubyteValue names the populated arm of NTNDArray's union-typed value
field — the frame is 8-bit greyscale. spinfo DODECA:IMAGE prints the
whole union and the dimension list alongside it.
What it is for
Testing an image client. Area-detector viewers are hard to develop against without a detector. This gives you a deterministic, always-running one.
Load-testing the wire. A 512×512 frame at 25 Hz is about 6 MB/s of PVAccess traffic through a single monitor — enough to expose buffering problems in a client.
Checking the NTNDArray encoding. It exercises the union-typed value
field and the dimension list, which is the part of the type most likely to
be implemented differently at the other end. See
Tables and images.
Crate map
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Spvirit is seven crates in one workspace. They are published separately, so
you depend on the layer you need and nothing above it. Six of them form the
layered stack below; the seventh, spvirit-calc, stands apart.
The layering
graph TD
types[spvirit-types]
codec[spvirit-codec]
client[spvirit-client]
server[spvirit-server]
tools[spvirit-tools]
py[spvirit-py]
codec --> types
client --> codec
client --> types
server --> codec
server --> types
tools --> client
tools --> server
tools --> codec
tools --> types
py --> client
py --> server
py --> codec
py --> types
The direction is strict and there are no cycles. spvirit-client and
spvirit-server do not depend on each other — the only thing they share is
the codec and the type vocabulary underneath it.
What each one is for
| Crate | Depends on | You want it when | API docs |
|---|---|---|---|
spvirit-types | — | You need the Normative Type structs (NtScalar, NtTable, NtNdArray, NtEnum, ScalarValue) without any I/O. Pure data and validation. | docs.rs |
spvirit-codec | spvirit-types | You are encoding or decoding PVAccess frames yourself — a proxy, an analyser, a test harness. | docs.rs |
spvirit-client | spvirit-codec | You are reading, writing, or monitoring PVs from Rust. | docs.rs |
spvirit-server | spvirit-codec | You are serving PVs: a soft IOC, a simulator, a gateway. | docs.rs |
spvirit-tools | client + server | You want the sp* command-line programs. Also usable as a library, but it exists mainly to ship binaries. | docs.rs |
spvirit-py | client + server | The spvirit Python module. A PyO3 extension, not a pure-Python package. | Python API |
A seventh crate, spvirit-calc
(docs.rs), implements
the EPICS CALC expression language. It is a workspace member and is published
alongside the rest, but nothing in the diagram above depends on it — it stands
alone, and you add it explicitly if you want it.
Incomplete.
spvirit-calcis a work in progress. Its conformance corpus (spvirit-calc/tests/base_corpus.rs, transcribed from EPICS Base'sepicsCalcTest.cpp) still has failing cases in the parser's conditional/:error classification, so that corpus test is currently commented out. The per-module unit tests pass, but treat the crate as unfinished. See Known gaps.
This book is the tutorial; the docs.rs pages are the exhaustive per-item
reference. They are generated from the same source tree and versioned with
each release, so latest always matches the newest published version.
Versions
All are released together from the workspace, so their version numbers
move in step. spvirit-py is published to PyPI rather than crates.io and
carries its own version.
Feature flags
Only spvirit-tools is feature-gated:
| Feature | Default | Gates |
|---|---|---|
client | yes | spget, spput, spmonitor, spinfo, splist, spsine, spget_compare |
server | yes | spserver, spdodeca |
tui | yes | spexplore, spsearch; also required (with server) by sptable |
All three are on by default. A build with --no-default-features produces
no binaries at all — a fact worth knowing before you spend ten minutes
wondering where they went. See
Installation.
spvirit-types, spvirit-codec, spvirit-client and spvirit-server
declare no features.
Where to read further
The Developer guide walks each crate's internals with file-and-line citations — Types and Codec, Server, Client and Tools, Python Bindings.
Record types
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
RecordType has seventeen variants
(spvirit-server/src/types.rs:24). Not all seventeen are reachable by
every route: the handle API, the server builder, and .db loading each
cover a different subset. This page is that matrix, checked against the
constructors that actually exist.
If you are not sure whether you want a record at all, read Records vs raw NT first.
The matrix
| Record | EPICS meaning | Handle API | Builder | .db | Writable |
|---|---|---|---|---|---|
ai | analog in | Pv::ai | .ai() | record(ai, …) | no¹ |
ao | analog out | Pv::ao | .ao() | record(ao, …) | yes |
bi | binary in | Pv::bi | .bi() | record(bi, …) | no |
bo | binary out | Pv::bo | .bo() | record(bo, …) | yes |
longin | 32-bit integer in | Pv::longin | — | — | no |
longout | 32-bit integer out | Pv::longout | — | — | yes |
stringin | string in | Pv::string_in | .string_in() | record(stringin, …) | no |
stringout | string out | Pv::string_out | .string_out() | record(stringout, …) | yes |
mbbi | multi-bit binary in | Pv::mbbi | .mbbi() | parses, then refused² | yes³ |
mbbo | multi-bit binary out | Pv::mbbo | .mbbo() | parses, then refused² | yes³ |
waveform | array | PvArray::waveform | .waveform() | record(waveform, …) | yes |
aai | array analog in | PvArray::aai | .aai() | record(aai, …) | no |
aao | array analog out | PvArray::aao | .aao() | record(aao, …) | yes |
subArray | window onto an array | — | .sub_array() | record(subarray, …) | no |
NtTable | table payload | — | .nt_table() | refused² | yes⁴ |
NtNdArray | image / detector frame | — | .nt_ndarray() | refused² | yes⁴ |
Generic | arbitrary structure | — | .generic() | refused² | yes |
¹ An ai becomes writable when SIMM is set — the simulation-mode path
(spvirit-server/src/types.rs:308).
² RecordType::from_db_name maps twelve .db spellings, including mbbi
(also spelled ntenum) and mbbo. Those two then reach an arm in
spvirit-server/src/db.rs:550 that prints "is not a standard EPICS Base
record type and cannot be loaded from .db files" and drops the record.
mbbi and mbbo are standard EPICS Base record types; the message is
wrong. See Known gaps.
³ Writable in the sense that the record accepts write access. A wire PUT of an enum index is currently dropped — see Known gaps.
⁴ Writable via a client PUT as well as store.put_nt(). The NtTable/
NtNdArray arms of RecordInstance::apply_put
(spvirit-server/src/apply.rs:609) apply the wire fields and restamp the
record.
Reading the columns
Handle API — Pv<T> and PvArray constructors in
spvirit-server/src/pv.rs. These give you a handle you keep after the
server starts, so you can set(), scan(), calc() and on_put() on it.
This is the level most of Part III works at.
Builder — PvaServer::builder().ai(…) and friends
(spvirit-server/src/pva_server.rs). Declares records inline; you reach them
afterwards through the store rather than through a handle. The builder is
the only route to sub_array, nt_table, nt_ndarray and generic.
.db — text database files, as EPICS Base uses them. See
Serving a .db file for the fields spvirit
acts on.
Writable — whether the server grants write access
(RecordInstance::writable, spvirit-server/src/types.rs:303). Output
record types are always writable; a handful of input types are too, for the
reasons in the footnotes. Everything else answers a PUT with
Write access denied.
The derived record
Pv::calc (spvirit-server/src/pv.rs:392) is not a record type. It builds an
ai whose value is recomputed from other Pv<f64> handles whenever one of
them changes — the equivalent of a calc record's CALC expression, written
as a Rust closure. The builder spelling is .link(output, inputs, compute).
Python
The Python module exposes the same handle-API set:
ai, ao, bi, bo, string_in, string_out, longin, longout,
mbbi, mbbo, waveform, aai, aao, calc, plus the generic pv and
scalar constructors (spvirit-py/src/lib.rs:54). See
Python API.
Python API
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Every progressive example in Part III shows Rust and Python side by side, so the tutorial route is the one to take first. This page is the orientation map: what the module contains, and where the full reference lives.
The complete API reference is
spvirit-py/README.md
— around a thousand lines covering every class, method and keyword argument.
It is not duplicated here; this page tells you which part of it you want.
Every object also carries a docstring, so help(spvirit.ai) and
help(spvirit.Server) work at the interpreter prompt.
For the Rust side there is generated reference documentation on docs.rs:
types ·
codec ·
client ·
server ·
tools ·
calc. The Python
module is a thin layer over spvirit-client and spvirit-server, so when a
Python docstring is terse the Rust page for the same call is often the
fuller answer. See the crate map.
Install
pip install spvirit
A compiled PyO3 extension, not a pure-Python package. Wheels ship for the usual platforms; building from source needs a Rust toolchain. See Installation.
The four layers
| Layer | Import | What it gives you |
|---|---|---|
| Typed PV handles | spvirit.ai, spvirit.ao, … | IOC-style records you keep a handle to. The one to start with. |
| Server + store | spvirit.Server | Runs the PVAccess server; store reaches records by name. |
| Client | spvirit.get, spvirit.put, spvirit.monitor, spvirit.Channel | Reading and writing other people's PVs. |
| Low level | spvirit.lowlevel, spvirit.codec | Raw frames and wire encoding, for proxies and analysers. |
Constructors
The module-level PV constructors mirror the Rust handle API one for one:
ai ao bi bo string_in string_out longin longout
mbbi mbbo waveform aai aao calc pv scalar
pv and scalar are the generic forms — you pass the type explicitly rather
than getting it from the constructor name. The full type-coverage table (which
NT scalar types each constructor accepts) is in the
README's NT scalar type coverage section.
Sync and async
Most operations come in both flavours: set/set_async, get/get_async,
connect/connect_async and so on. The sync forms release the GIL while
they block, so they are safe to call from a thread. The README's
Threading and async model section is the one to read before you mix them.
The rule that catches everyone
Attach callbacks before starting the server. on_put, scan and calc
must be registered on a handle while it is still unbound. Once
Server.start() has run, the handle is bound and a late on_put will not
fire. The same rule holds in Rust, but Python makes it easier to trip over
because the server object is mutable and the failure is silent. See
Reacting to writes and
Troubleshooting.
Examples
spvirit-py/examples/ holds around thirty runnable scripts — one concept
each. The ones the book's chapters use directly:
| Script | Chapter |
|---|---|
demo_first_pv.py | Your first PV |
demo_scalars.py | Serving scalars |
demo_get.py, demo_put.py | Reading and writing |
demo_monitor.py | Monitoring changes |
demo_on_put.py | Reacting to writes |
demo_scan.py, demo_calc.py | Simulating a device |
demo_waveform.py | Arrays and waveforms |
demo_enums.py | Enums and binary records |
demo_alarms.py | Alarms and severity |
demo_table.py | Tables and images |
For Custom data sources the Python route is
the demo_source_*.py family — sensor, async, multi, passthrough,
aggregate, rpc, wildcard. The rest of the directory — gateways, stress
tests, wire inspectors, the 10 000-PV farm — is listed in the README's
Examples section.
Internals
How the bindings are put together, and why they are sync-first, is in Python Bindings in the developer guide.
Troubleshooting
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Real failures, in rough order of how often they happen.
A client cannot find the server
$ spget DEMO:TEMP
Error: Timeout("search response")
PvGetError::Timeout carries the stage it gave up at
(spvirit-client/src/types.rs:64), and the stage tells you where to look.
"search response" — nothing answered the UDP broadcast. Either no
server is running, or the search never reached it. Check, in this order:
- Is the server up?
splistwith no arguments broadcasts and lists every server that answers. - Same subnet? PVA searches go out on UDP 5076 by broadcast. Across a
router, set
EPICS_PVA_ADDR_LISTto the server's address, or pass--server host:portto skip the search entirely. - Firewall? Windows blocks inbound UDP on a new binary the first time it binds. The server starts fine and answers nothing.
- Multi-homed host? The search leaves on one interface.
spsearchshows what is actually arriving — if it shows nothing, you are on the wrong NIC.
"read header", "name server connect", "name server handshake" —
something answered, and then the TCP leg failed. The commonest cause is a
stale EPICS_PVA_NAME_SERVERS pointing at a name server that is gone: the
client goes straight to TCP, skips the broadcast, and waits. Unset it and
retry before blaming the server.
The client reads five environment variables:
EPICS_PVA_ADDR_LIST, EPICS_PVA_AUTO_ADDR_LIST, EPICS_PVA_NAME_SERVERS,
EPICS_PVA_CONN_TMO, and EPICS_PVA_ENABLE_GET_FIELD_FALLBACK.
The server is found but the PV is not
A server answers a search only for names it serves, so a timeout on one PV
while another works means the name is wrong — a typo, or a record that
failed to load. Start the server in the foreground and read the startup
line: it reports how many PVs came out of the .db file.
If the record is mbbi or mbbo and you loaded it from a .db file, it was
dropped and a message went to stderr. If it is longin or longout, it was
dropped with no message at all — the PV count in the startup line is your
only clue. See Known gaps.
Write access denied
$ spput VAC:PRESSURE 1e-5
VAC:PRESSURE ERROR protocol error: PUT init error: Write access denied
The record type is an input type. ai, bi, stringin, longin, aai
and subArray refuse writes; their output counterparts accept them. The
matrix is on Record types.
If you meant it to be writable, you wanted ao rather than ai. If you
want a simulated readback you can also poke, set SIMM — an ai in
simulation mode is writable (spvirit-server/src/types.rs:308).
The write is accepted and nothing changes
Three different causes, and they look identical from the client:
A validator rejected it. Then you get an error, not silence — check the
exit status. spput returns non-zero on a rejected write.
It is an enum. A wire PUT of an NtEnum index currently reports success
and changes nothing. Known gaps.
It is a Generic record. The Generic arm of RecordInstance::apply_put
(spvirit-server/src/apply.rs:639) is a no-op. Write it server-side with
store.put_nt().
A monitor looks frozen
The value is moving but no updates arrive. Almost always the deadband:
should_post_update (spvirit-server/src/simple_store.rs:545) suppresses
the post, not the store, when the record is a numeric scalar, MDEL is
greater than zero, the severity has not changed, and the delta is under
MDEL.
Confirm with spget — if the value has moved and the monitor has not, that
is the deadband. Set MDEL to 0 to post everything.
Two things that are not the cause: alarm transitions always post regardless
of the deadband, and ADEL is parsed and exposed but not wired into any
posting logic.
Python: on_put never fires
You attached it after Server.start(). Handles are unbound until the server
starts and bound afterwards; on_put, scan and calc must be registered
while the handle is still unbound. There is no error — the callback simply
sits there.
Register everything before you start:
temp = spvirit.ao("DEMO:SETPOINT", 20.0)
temp.on_put(lambda pv, v: print("set to", v)) # before
server = spvirit.Server([temp])
server.start() # not after
on_put fires twice for one write
Only for a rejected write, and only from spput. When the full PUT flow
fails, spput silently retries with the simple flow
(spvirit-tools/src/bin/spvirit_put.rs:214), so the server sees the write
twice. Pass --no-flow-fallback to suppress the retry — and write on_put
callbacks to be idempotent regardless.
Alarm limits are set but the severity stays NO_ALARM
Limits set through Pv::alarm_limits are published in the payload's
valueAlarm structure but never evaluated. Computed severity comes from the
server-level compute_alarms flag, which is off by default
(spvirit-server/src/server.rs:55) — spserver --compute-alarms, or
.compute_alarms(true) on the builder. See
Alarms and severity.
cargo build produced no binaries
spvirit-tools gates every binary behind a feature. A build with
--no-default-features builds the library and nothing else. See
Crate map.
spget prints 0 for a value that is not zero
That column is a display rendering, not the wire value: 5e-7 prints as
0. Use spget -F value to see the field as decoded.
Known gaps
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Divergences between what spvirit does and what an EPICS user would expect, found while writing this book and confirmed by running the code. Each one is documented where it bites, in the relevant chapter; this page collects them so you can scan the list before you spend an afternoon on one.
Nothing here is a plan. These are findings, not commitments.
1. An enum write is accepted and dropped
What happens. spput SIM:MODE --json '{"value":{"index":2}}' prints
OK. The record does not change.
Why. The NtEnum arm of RecordInstance::apply_put
(spvirit-server/src/apply.rs:611) accepts a field literally named
value carrying a scalar integer. A wire PUT of an enum delivers value as
a sub-structure, so no branch matches, changed stays false, and the
operation reports success.
Consequence. The one failure mode worse than an error: a write that says
it worked. mbbi/mbbo records are read-only in practice over the wire.
Where it is documented. Enums and binary records,
spput.
2. .db refuses record types that EPICS Base has
What happens. Given this file:
record(ai, "GAP:OK") { field(VAL, "1.0") }
record(longin, "GAP:LONGIN"){ field(VAL, "7") }
record(mbbo, "GAP:MBBO") { field(ZRST, "Off") field(ONST, "On") }
$ spserver --db-file gap_test.db
INFO spserver: Loaded DB file 'gap_test.db' with 1 PVs
Record 'GAP:MBBO': type 'mbbo' is not a standard EPICS Base record type and cannot be loaded from .db files
One of three records loaded.
Why. Two separate holes. mbbi/mbbo are recognised by
RecordType::from_db_name and then rejected by an arm in
spvirit-server/src/db.rs:550 whose message claims they are not standard
EPICS Base record types — which they are. longin/longout are not in
from_db_name at all, so they fall out through a ?
(spvirit-server/src/db.rs:315) and vanish with no message whatsoever.
Consequence. A .db file written for a real IOC loses records, and the
diagnostic is either misleading or absent. The silent case is the dangerous
one: the count in the startup line is the only clue.
Workaround. Create those four types through the handle API
(Pv::longin, Pv::mbbo, …). See Record types.
Where it is documented. Serving a .db file.
3. Alarm limits on a handle are published but never evaluated
What happens. Pv::alarm_limits(lolo, low, high, hihi) puts the limits
in the payload's valueAlarm structure. Severity stays NO_ALARM however
far the value goes past them.
Why. Severity computation is gated on the server-wide compute_alarms
flag, which defaults to false (spvirit-server/src/server.rs:55). The
handle-level limits do not turn it on.
Consequence. A PV that looks alarmed to a human reading the metadata and
healthy to anything that reads severity.
Workaround. spserver --compute-alarms, or .compute_alarms(true) on
the builder. That path derives MINOR from LOW/HIGH and MAJOR from
LOLO/HIHI, and it works.
Where it is documented. Alarms and severity,
spserver.
4. spput delivers a rejected write twice
What happens. A write a validator rejects reaches the server's on_put
twice; an accepted write reaches it once.
Why. When the full EPICS-Base-style PUT flow fails, spput falls back to
the simple flow without saying so
(spvirit-tools/src/bin/spvirit_put.rs:214).
Consequence. Any on_put with a side effect — a log line, a counter, a
hardware poke — doubles up on exactly the writes you were trying to refuse.
Workaround. --no-flow-fallback, and idempotent callbacks.
Where it is documented. spput,
Reacting to writes.
5. ADEL is parsed and exposed but not applied
MDEL gates monitor posts (spvirit-server/src/simple_store.rs:545).
ADEL — the archive deadband — is read out of the .db file and readable
as a field, and no posting logic consults it. A .db that relies on ADEL
behaves as though it were absent.
6. Wire PUT is not wired for generic structures
The Generic arm of RecordInstance::apply_put
(spvirit-server/src/apply.rs:639) is a no-op — it returns false without
looking at the PUT body. NtTable and NtNdArray are wired (both arms call
into apply.rs's table/ndarray helpers), so a generic record is now the one
kind that reports itself writable and silently discards every wire PUT — the
same failure shape as gap 1. Write it server-side with store.put_nt().
7. The builder has no longin/longout
PvaServerBuilder covers fifteen record constructors and omits these two,
which both the Rust handle API (Pv::longin, Pv::longout) and the Python
module do have. Combined with gap 2, the builder and .db are the only two
routes that cannot produce a longin. The matrix is on
Record types.
8. CANCEL_REQUEST is unimplemented
The server answers PVA command CANCEL_REQUEST with
"CANCEL_REQUEST command is not supported"
(spvirit-server/src/handler.rs:1773). ACL_CHANGE, MESSAGE,
MULTIPLE_DATA, ORIGIN_TAG and commands 14 and 16 likewise return errors.
Clients that cancel a request rather than destroying the channel will see the
error; the common clients do not.
9. spvirit-calc is incomplete
What happens. The spvirit-calc crate implements the EPICS CALC
expression language, but it does not yet pass its own conformance corpus.
Why. The corpus (spvirit-calc/tests/base_corpus.rs), transcribed
case-for-case from EPICS Base's epicsCalcTest.cpp, still has 2 of 686 cases
failing. Both are error-classification mismatches in the parser's handling of
conditional/: syntax: "1?" is reported as MissingOperand where Base
expects a conditional error, and ":1" is reported as a conditional error
where Base expects a syntax error.
Consequence. The base_corpus conformance test is currently commented out
so the workspace test suite is green. The per-module unit tests in
spvirit-calc/src/ all pass, but the crate should be treated as unfinished
until the corpus is re-enabled and passes.
Where it is documented. Crate map.
What is not on this list
Behaviour that is deliberate and merely surprising lives in the chapters, not
here — spget accepting exactly one PV name, spsine printing nothing on
success, discover and off suppressing enumeration but not discovery,
spget's value column being a display rendering rather than the wire value.
Each is a gotcha in its own tool page.
The full engineering picture — everything above plus the internal to-do list — is in Current State and Roadmap.
Spvirit Developer Guide
Internal handover documentation for developers taking over Spvirit — a pure-Rust implementation of the EPICS PVAccess protocol (client, server, codec, CLI tools, Python bindings).
This guide covers internals: architecture, per-crate deep dives with
file/line references, testing, release process, and the exact state of
in-flight work. For the user-facing API story, read
Part I through
Part III of this site first, and
spvirit-py/README.md
for the full Python reference. The top-level
README.md is
a landing page that points here.
Chapters
| # | Chapter | Read it when |
|---|---|---|
| 01 | Architecture Overview | Day one — the crate graph, the two protocol layers, server data flow, and the invariants everything relies on |
| 02 | spvirit-types & spvirit-codec | Before touching the data model or wire format |
| 03 | spvirit-server | Before touching the server: Source model, store, protocol runtime, handles, alarms/deadbands |
| 04 | spvirit-client & spvirit-tools | Before touching search/get/put/monitor or the CLI tools |
| 05 | spvirit-py | Before touching the Python bindings — especially the threading model |
| 06 | Testing Guide | Before writing tests; how to run the interop suites |
| 07 | Build, CI, and Release | Before releasing anything; repo conventions |
| 08 | Current State & Roadmap | First, if you're picking up work — uncommitted changes, the in-flight Python value-types plan, known-gaps triage list |
Day-one checklist
git clone https://github.com/ISISNeutronMuon/spvirit && cd spvirit
cargo build --release
cargo test --all # should be green
# See it work — two terminals:
cargo run -p spvirit-server --example simple_server
cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE
# Python bindings:
cd spvirit-py && python -m venv .venv
.venv\Scripts\Activate.ps1 # Windows; source .venv/bin/activate elsewhere
pip install maturin && maturin develop
python tests/test_pv_handles.py # expect ALL OK
Then:
- Read chapter 08 and run
git status/git log origin/main..main— there is uncommitted work and unpushed commits at handover. - Skim the top-level README's "Key Concepts" if EPICS/PVAccess is new to you, then chapter 01 here.
Orientation in 60 seconds
Six crates, strict layering: types (pure data) → codec (wire format) →
client/server → tools (CLIs + integration tests) and py (PyO3).
Everything on the wire is a Normative Type; the server offers an IOC-style
record level (records, alarms, deadbands, auto-timestamps) as sugar over a
raw-NT level (put_nt/get_nt, custom Source providers). Default ports:
TCP 5075, UDP 5076. Interop is validated against EPICS Base, p4p/pvxs, and
PVAccessJava.
How work is planned here
Substantial features follow a spec → plan → TDD execution workflow: a design
spec, then a checkbox implementation plan (exact commands, per-task commits,
Conventional Commit messages), then task-by-task execution. Those working
documents are kept out of the repository — the durable record is this guide,
the commit history, and the tests. One such plan — Python NT value-type
selection — is mid-flight; see chapter 08 before touching spvirit-py or
spvirit-server/src/pv.rs.
Where to get answers
- Protocol questions: the pvAccess Protocol Specification and pvxs (the reference implementation this project most closely mirrors).
- "Why is this code like this": the commit history is the archaeology — commits are per-task and carry the reasoning in their messages.
- Wire debugging:
spsearch(search traffic TUI),spget --raw/spmonitor --raw(hex dumps),spget_compare(byte-compare against captures), and the related spvirit-scry capture tool.
Architecture Overview
What this project is
Spvirit is a from-scratch Rust implementation of the EPICS PVAccess protocol: wire codec, client, server (softIOC-like), CLI tools, and Python bindings. It interoperates with EPICS Base, p4p/pvxs, and PVAccessJava.
If you are new to EPICS, read the "Key Concepts" section of the top-level
README.md first — it explains PVs, records, .db files, and Normative
Types (NT) with diagrams. This guide assumes that vocabulary.
Crate dependency graph
spvirit-types (pure NT data model, zero deps)
│
spvirit-codec (PVA wire format + PVD codec + state tracker)
┌──┴──────────┐
spvirit-client spvirit-server
└──┬──────────┘
┌────────┴────────┐
spvirit-tools spvirit-py
(CLI binaries) (PyO3 bindings)
- spvirit-types —
ScalarValue,NtScalar,NtPayload, etc. Everything the wire carries, as plain Rust data. Chapter 02. - spvirit-codec — encode/decode for both protocol layers: the PVA message layer (headers, commands) and the PVD data layer (introspection descriptors, values, bitsets). Also a passive connection-state tracker used by the diagnostic tools. Chapter 02.
- spvirit-client — search/discovery, channel lifecycle, get/put/monitor/ info. Chapter 04.
- spvirit-server — the
Sourceprovider model,SimplePvStorerecord store, protocol runtime (UDP search, TCP handler, beacons, monitors),.dbparser, and the typedPv<T>handle layer. Chapter 03. - spvirit-tools — 11 CLI binaries (
spget,spput,spmonitor,spexplore,spserver, …) plus the workspace's integration/interop test suite. Chapter 04. - spvirit-py — PyO3 bindings mirroring the handle API in Python, plus Python-defined dynamic sources and a low-level channel/codec surface. Chapter 05.
The two protocol layers
PVAccess is two nested encodings, and the codec keeps them in separate modules:
- PVA message layer (
epics_decode.rs/spvirit_encode.rs): 8-byte header (magic 0xCA, flags carrying byte order + segmentation, command byte, payload length), ~20 command types (Search, CreateChannel, the Op family for GET/PUT/MONITOR/RPC, Beacon, …). - PVD data layer (
spvd_decode.rs/spvd_encode.rs): self-describing structured data — type descriptors (FieldDesc/StructureDesc) with a per-connection introspection cache, values (DecodedValue), and bitsets for delta updates.
Key asymmetry to internalize: NT types flow into the encoder; the decoder
emits DecodedValue, a separate tree. Consumers (server, client, py) each
convert DecodedValue to what they need — there is no shared reverse
mapping.
Server data flow (the picture to keep in your head)
┌────────────── PvaServer::run ──────────────┐
client SEARCH ──► UDP responder TCP handler ◄── client TCP
│ │ ▲
│ decode PUT │ frames (writer task)
▼ ▼ │
SourceRegistry ──► SimplePvStore ──► MonitorRegistry
(priority list) (records, MDEL, (per-sub delta
builtin @0 validators, links, frames, pipeline
record-fields @10 on_put, timestamps) credits)
user sources) ▲
│ set_value / set (internal writes)
scan tasks / Pv<T> handles / links
Two write entry points converge on the store: wire PUTs (via the handler →
registry → Source::put) and internal writes (scan callbacks, Pv::set,
link evaluation → store.set_value). Both end at
MonitorRegistry::notify_monitors, which builds full-or-delta frames per
subscriber and pushes bytes into each connection's writer channel.
Two API levels, everywhere
The project consistently offers an IOC-style record level and a raw-NT level (the README's "IOC-style records vs raw NT PVs" table is the user-facing version of this):
| Record level | Raw NT level | |
|---|---|---|
| Rust server | Pv<T> handles, builder methods, .db files | put_nt/get_nt, hand-built RecordInstance, custom Source |
| Python | spvirit.ai(...) etc., Server(pvs=[...]) | Store.put_nt, NtScalar/NtTable classes, dynamic sources + Notifier |
| Behavior | alarms computed, timestamps stamped, MDEL applied | caller owns metadata; every post goes out |
Keep new features consistent with this split: record-level conveniences should be sugar over the NT level, not a parallel implementation.
Design invariants worth knowing before changing anything
- Timestamps: a missing (
None/epoch-0) timestamp is stamped at encode time, which breaks monitor deltas and Archiver Appliance ingestion. All mutation paths stamp update time; keep it that way. - The introspection registry is per-connection state — decoders must be reused across a connection's packets (0xFE type refs).
- First-claim-wins source priority —
claimmust be cheap and idempotent becauseget/put/subscribere-claim. - on_put (post-apply, can't reject) vs PUT validator (pre-apply, can
reject) are different mechanisms; the Python
on_putmaps to the validator. - Monitor bitset ordering is heuristic (three decode variants + scoring) because implementations disagree; be careful "simplifying" it.
- Bool coercion trap:
decoded_to_scalar_valuechecks truthiness before numeric types; typed paths overridefrom_decodedto avoid it.
spvirit-types & spvirit-codec — Foundation Crates
These two crates are the foundation of the workspace: spvirit-types is the pure
data model, spvirit-codec is the wire format. Everything else (-client,
-server, -tools, -py) depends on both.
spvirit-types (NtPayload, NtScalar, ScalarValue, PvValue, …) ← zero dependencies
│
▼
spvirit-codec (PVA wire codec + PVD codec + connection state tracker)
│ re-exports spvirit_types at crate root (lib.rs:36)
▼
spvirit-client / spvirit-server / spvirit-tools / spvirit-py
Deps: spvirit-types has none; spvirit-codec uses only spvirit-types,
hex, tracing. Edition 2024 throughout.
spvirit-types
The entire crate is one file, spvirit-types/src/lib.rs (~617 lines): pure
structs/enums for the Normative Type (NT) data model, plus builder methods and
validation. No I/O, no wire format.
| Type | Location | Role |
|---|---|---|
ScalarValue | lib.rs:9 | Tagged union of the twelve NTScalar value types (Bool, I8–I64, U8–U64, F32, F64, Str) |
ScalarArrayValue | lib.rs:25 | Array counterpart; len(), element_size_bytes(), type_label() at lib.rs:40–95 |
NtAlarm / NtTimeStamp / NtDisplay / NtControl | lib.rs:98–147 | Normative sub-structures |
NtScalar | lib.rs:150 | The big one: value + flattened alarm/display/control/valueAlarm/units + optional time_stamp (lib.rs:185) |
NtScalarArray | lib.rs:352 | Array payload |
NtTable / NtTableColumn | lib.rs:373–405 | Table; validate() checks column-length equality |
NtNdArray + NdCodec/NdDimension/NtAttribute | lib.rs:408–504 | Image/detector model; validate() checks dims × element size vs uncompressed_size |
NtEnum | lib.rs:524 | index + choices; selected() |
PvValue | lib.rs:560 | Recursive value tree (Scalar/ScalarArray/Structure) so this crate can represent arbitrary structures without depending on the codec |
NtPayload | lib.rs:570 | Top-level union: Scalar/ScalarArray/Table/NdArray/Enum/Generic{struct_id, fields} — the primary hand-off type between server/client and codec |
NtScalar::update_alarm_from_value (lib.rs:285) computes alarm severity from
the HIHI/HIGH/LOW/LOLO limits — this is the server's alarm engine, but note it
lives here in the types crate.
Footgun: NtScalar.time_stamp is Option
None means the encoder stamps SystemTime::now() at encode time
(non-deterministic — it breaks monitor delta detection of secondsPastEpoch
and makes tests flaky). Some is stable. Documented at lib.rs:179–184. The
server now stamps timestamps on every mutation and (in-flight change) at
store-entry time precisely because of this.
spvirit-codec
| File | ~Lines | Contents |
|---|---|---|
lib.rs | 36 | Module decls + curated re-exports (also re-exports spvirit_types) |
encode_common.rs | 28 | encode_size (PVA varint) + encode_string |
epics_decode.rs | 2108 | PVA wire-format decode: header, control flags, ~20 command payload structs, PvaPacket::decode_payload dispatch, PvaOpPayload |
spvd_decode.rs | 1507 | pvData (PVD) introspection + value decode: TypeCode, FieldDesc, StructureDesc, DecodedValue, PvdDecoder (incl. introspection registry, bitset decode) |
spvd_encode.rs | 2571 | pvData encode / NT serialization: struct-desc encoding, per-NT-type encoders, bitset/delta/monitor encoding, PvRequest encode/decode, projection/filtering |
spvirit_encode.rs | 1372 | PVA wire-format encode: encode_header, all request/response builders (search, create-channel, op init/data/status, monitor, beacon) |
spvirit_state.rs | 1358 | Connection state tracker: CID↔SID↔PV-name mapping, operation states, search cache, snapshots/stats (used by the sniffing/diagnostic tools) |
How the wire protocol works
Framing. Every PVA message is an 8-byte header (magic 0xCA / version /
flags / command / payload_length) + payload. Decode entry point:
PvaPacket::new → decode_payload (epics_decode.rs:205), which dispatches on
the command byte (0=Beacon, 1=ConnValidation, 3=Search, 4=SearchResp,
7=CreateChannel, 8=DestroyChannel, 9=ConnValidated, 10–14/16/20=Op,
15=DestroyRequest, 17=GetField, 18=Message, 21=CancelRequest, 22=OriginTag …).
Encode entry point: encode_header (spvirit_encode.rs:66); each
encode_*_response/request builds a payload then prepends the header.
Byte order is decided per-packet by header flag bit 7. Every integer
read/write branches on is_be — there is no abstraction layer, the
if is_be {…} else {…} pattern is repeated everywhere. Connections cache
their order in ConnectionState.is_be (defaults little-endian).
Sizes/strings use the PVA varint: 1 byte < 254, 0xFE + u32 above, 0xFF
= null. Warning: four near-identical size codecs exist
(encode_common::encode_size, epics_decode::decode_size,
spvd_encode::encode_size_pvd, spvirit_encode::encode_size_pva) — if you
change one, check the others.
Introspection / type descriptors. PVD structures are described by
FieldDesc/StructureDesc trees. Parse path: PvdDecoder::parse_field_desc
(spvd_decode.rs:352) → parse_type_desc → parse_structure_desc. Tag bytes:
0x80 structure, 0x81 union, 0x82 variant (+0x08 for array forms),
scalar/array mode bits & 0x18, base type & 0xE7. Encode side:
spvd_encode::encode_structure_desc (spvd_encode.rs:33) plus per-NT descriptor
builders (nt_scalar_desc:274, nt_scalar_array_desc:708, nt_table_desc:764,
nt_ndarray_desc:933, nt_enum_desc:1191, dispatcher nt_payload_desc:1304).
The introspection registry is stateful. 0xFD = "full type with id"
(parse + cache under a u16 key), 0xFE = "only id" (look up cached type).
The registry lives inside a RefCell in PvdDecoder (spvd_decode.rs:296) —
a single PvdDecoder instance must be reused across a connection's packets
or 0xFE references won't resolve. This is easy to get wrong.
Bitsets (monitor deltas). Bit 0 = whole structure; field bits start at
bit 1; nested structs consume a contiguous bit block (count_structure_fields
flattens the count). Decode has three variants for the overrun-bitset
ordering (decode_structure_with_bitset, _and_overrun, _then_overrun,
spvd_decode.rs:853–911) because real implementations disagree on the wire
ordering; PvaOpPayload::decode_with_field_desc (epics_decode.rs:1468) tries
all three for MONITOR packets and scores the results
(choose_best_decoded_multi / score_decoded). This is a heuristic, not
spec-exact — a likely source of future bugs. Encode side:
encode_nt_payload_delta (spvd_encode.rs:1916), compute_changed_bits:1812,
encode_structure_bitset:464.
Op payloads. PvaOpPayload::new (epics_decode.rs:1371) handles client vs
server field-offset differences, the conditional status prefix, PV-name
extraction, and parses introspection on INIT responses. decoded_value is
filled later once a field_desc is known.
Value decode (PvdDecoder::decode_value, spvd_decode.rs:706) is recursive
over FieldType. Safety caps: scalar arrays 4 M elements, string arrays 4096,
struct arrays 256, unions 128 — oversized arrays are truncated (string/struct
truncation can desync the decode stream).
Segmentation is parsed but not reassembled. The flags byte carries
first/middle/last segment bits and they are decoded (epics_decode.rs:83–101),
but PvaPacket only decodes a single complete buffer — reassembly is the
caller's job. spvirit-server/src/handler.rs and decode.rs implement their
own reassembly; the codec itself does not. This is an architectural gap if
you ever handle large segmented values in a new consumer.
Connection state tracker (spvirit_state.rs)
PvaStateTracker is fed protocol events (on_search,
on_create_channel_request/response, on_op_init_request/response,
on_op_activity, on_destroy_channel, …) and maintains CID↔SID↔PV-name
maps, per-operation field_descs, a search cache, TTL-based cleanup (5 min /
40 k channels default) and snapshot/stats reporting. PV-name resolution
(resolve_pv_name, spvirit_state.rs:741) is deliberately best-effort for
mid-stream packet captures — the single-channel fallback is disabled when
multiple ops exist to avoid mis-attribution on multiplexed connections
(Phoebus). Used by the diagnostic TUI tools, not by the server/client
runtimes.
Data-flow asymmetry (important)
NT types flow into the encoder (spvd_encode takes spvirit-types
structs → wire bytes + StructureDesc). The decoder emits DecodedValue, a
separate codec-local tree — there is no DecodedValue → NtPayload reverse
mapping in these crates; each consumer interprets DecodedValue itself
(see spvirit-server/src/convert.rs, spvirit-py/src/convert.rs).
Known issues & sharp edges
PvaHeader::newpanics on < 8 bytes; usetry_newfor untrusted input.parse_introspection_with_lenhas a dead if/else (spvd_decode.rs:583–591) — both branches insert the same value; harmless but confusing.StructureArraynull elements decode to an empty-struct placeholder, not a true null — lossy.decoded_to_scalar_value-style truthy-first conversions live in consumers; see the server chapter for the bool-coercion bug that everyPvScalarimpl works around.- No captured-packet fixture corpus. All tests build byte arrays inline or round-trip encode→decode. There is no regression corpus of real EPICS traffic — consider adding one (e.g. captures from pvxs/p4p/PVAccessJava) before making codec changes.
- There are no TODO/FIXME markers in either crate; incomplete areas are only discoverable by reading. The list above is the current known set.
Tests
All inline #[cfg(test)] modules at the bottom of each file: spvd_encode 18,
spvirit_encode 20, spvirit_state 15, epics_decode 9, spvd_decode 7, types 2.
Round-trip oriented (encode → decode → assert equality). The state-tracker
tests (spvirit_state.rs:1023–1350) double as documentation of the intended
state-machine semantics. Run with cargo test -p spvirit-codec -p spvirit-types.
spvirit-server — Server Architecture
The server crate provides .db parsing, the Source provider abstraction,
the PVAccess protocol runtime, and the ergonomic typed-handle (Pv<T>) layer.
It is the largest and most active crate.
Module map
All paths under spvirit-server/src/.
| File | ~Lines | Purpose |
|---|---|---|
handler.rs | 1867 | The core. TCP connection processor (handle_connection), UDP search responder (run_udp_search), TCP accept loop, ServerState, wildcard matching, GUID generation |
simple_store.rs | 1493 | SimplePvStore: in-memory Source backed by RecordInstances — value/NT writes, subscribers, MDEL gate, link evaluation, PUT application, NTScalar descriptor builders |
pv.rs | 1471 | Typed handle layer: Pv<T>, PvArray, AnyPv, PvScalar trait, pending/bound state machine, builder methods, attach |
pva_server.rs | 1247 | PvaServer + PvaServerBuilder (classic API), ServeBuilder/RunningServer (handle API), shared record-construction helpers make_scalar_record/make_output_record/make_array_record |
group.rs | 1161 | QSRV-style group PVs: info(Q:group) JSON parsing → GroupPvDef, and GroupSource composing members into NtPayload::Generic |
types.rs | 1011 | Record model: RecordType, ScanMode, LinkExpr, DbCommonState, RecordData, RecordInstance + value-mutation methods |
db.rs | 717 | EPICS .db file parser (regex, line-oriented) |
apply.rs | 511 | Pure functions applying a decoded PUT to NT payloads (apply_value_update, apply_table_put, apply_ndarray_put, …) |
record_fields.rs | 467 | QSRV-style field access: serves <pv>.<FIELD> and <pv>.<FIELD>$ as read-only channels; dbCommon defaults table |
monitor.rs | 311 | MonitorRegistry: per-PV subscriber lists, delta/full frame building, pipeline credit accounting |
convert.rs | 276 | DecodedValue → ScalarValue/ScalarArrayValue conversions |
pvstore.rs | 259 | The Source trait + PvInfo + SourceRegistry |
server.rs | 183 | Orchestration: run_pva_server_with_registry binds TCP/UDP/beacon and joins the tasks |
decode.rs | 128 | PUT-body decoding with fallback strategies + segmented-message reassembly |
beacon.rs | 67 | Periodic UDP beacon sender |
state.rs | 37 | Per-connection state: ConnState, MonitorSub, MonitorState |
The provider model: Source and SourceRegistry
Source (pvstore.rs:55) is the object-safe provider abstraction (modeled on
pvxs's provider registry). Methods return boxed futures: claim (returns
PvInfo{descriptor, writable} or None), get, put (returns
Vec<(name, payload)> of everything that changed — this is how forward-link
fan-out reaches monitors), subscribe (returns mpsc::Receiver<NtPayload>),
rpc (default Err), names.
SourceRegistry (pvstore.rs:125) is a priority-ordered list — the first
source to claim a name wins. Note that get/put/subscribe each call
claim again before dispatching, so claim must be cheap and idempotent.
Registration in PvaServer::run (pva_server.rs:672–690):
| Order | Label | Source | Claims |
|---|---|---|---|
| 0 | builtin | SimplePvStore | exact record names |
| 10 | record-fields | RecordFieldSource | <name>.<FIELD> refs |
| user | … | .source() extras | whatever they claim |
SimplePvStore
simple_store.rs:55. Holds RwLock<HashMap<String, PvEntry>> where PvEntry
= record + in-process subscriber senders + last_posted (the MDEL reference
value). Key paths:
- Public writers
set_value/set_array_value/put_ntbypass on_put/validators; each calls an_innerwriter thenevaluate_links. Source::put(simple_store.rs:410) — the wire PUT path: run the PUT validator (cloned out of the lock first, so user callbacks can't hold the lock across.await), apply viaRecordInstance::apply_put(apply.rs:546), which always restamps the record and reports whether the value changed, MDEL-gate the post (or force it when the PUT was client-stamped and the value did not change), spawn theon_putcallback as a detached task, evaluate links.- Links/calc:
evaluate_links(simple_store.rs:349) is a BFS overLinkDefs whose inputs include the changed PV, with avisitedset for cycle detection; usesset_value_innerto avoid re-triggering. - Descriptor builders for NTScalar/NTScalarArray live here
(simple_store.rs:645–903); Table/NdArray/Enum/Generic delegate to
spvirit_codec::spvd_encode::nt_payload_desc.
Protocol runtime
run_pva_server_with_registry (server.rs:112) binds TCP first (eager
EADDRINUSE so a failed start doesn't ghost-beacon), then spawns UDP-search,
TCP-accept and beacon tasks and joins them.
- UDP search (handler.rs:620): binds 5076 with SO_REUSEADDR/SO_REUSEPORT
(so a co-located p4p can share the port); answers Search packets whose names
the registry claims. Response IP: advertise_ip → non-unspecified listen_ip →
infer_udp_response_ip(connect-a-socket trick) → zeros. - TCP connection (handler.rs:778): per-connection reader plus a dedicated
writer task draining
mpsc::channel<Vec<u8>>(128). Handshake: SET_BYTE_ORDER → CONNECTION_VALIDATION → client's validation → CONNECTION_VALIDATED. Then the command dispatch (CreateChannel; Op 10 GET / 11 PUT / 12 PUT_GET / 13 MONITOR / 20 RPC; DestroyChannel; DestroyRequest; GetField; Echo; AuthNZ silently accepted). Segmented-message reassembly at handler.rs:878–946. Idle timeout enforced per-read. - Beacons (beacon.rs): tick every
beacon_period(default 15 s, 0 disables), reading anAtomicU16change counter.
Data flow: external PUT → monitor update
- Handler decodes the PUT and calls
state.sources.put(name, value)(handler.rs:1258). SimplePvStore::put: validator → apply under write lock → MDEL gate → in-process subscriber sends → returns changed(name, payload)list;on_putspawned; links evaluated (may append more changes).- Handler's
notify_changed_records(handler.rs:402) bumps the beacon counter and callsregistry.notify_monitorsper change. MonitorRegistry::notify_monitors(monitor.rs:102) builds per-subscriber frames — first frame full, later frames sparse delta (filtered subs) or full-or-suppressed (unfiltered, suppressed when unchanged) — respecting pipeline credits, and pushes bytes to each connection's sender.- The connection's writer task writes to the socket.
Internal writes (scan, Pv::set, links) enter at step 2 via
store.set_value and notify monitors from inside the store (the store holds
the registry via set_registry). So there are two notification origins:
protocol PUTs notify from the handler, internal writes from the store. Note
the beacon change counter is only bumped on the protocol-PUT path.
Concurrency summary
Tokio tasks: UDP-search loop, TCP-accept loop, beacon loop, one per
connection + one writer task per connection, one per .scan(), detached
on_put tasks, one per group-subscription fan-in. Locks: store pvs
(RwLock), monitor registry (Mutex), source registry (RwLock); each
Pv handle's shared state is a std Mutex held only briefly, never
across .await. Channels: per-connection outbound (128), per-subscriber
NtPayload (64).
Record model and .db parsing
RecordType(types.rs:24): 17 kinds;from_db_namemaps.dbstrings (mbbi|ntenumboth →Mbbi);is_output()gates writability.RecordData(types.rs:128): one variant per record family carrying the NT payload + record-specific fields (INP/OUT/DOL/DRVL/DRVH/SIML/…).nt()/nt_mut()panic on non-scalar variants (types.rs:244, 256) — usent_scalar_mut()(fallible) in generic code.RecordInstance(types.rs:294) addsraw_fields: HashMap<String,String>(verbatim.dbfields — used by the record-fields source and MDEL lookup).set_scalar_value(types.rs:430) does exhaustive cross-type numeric coercion and timestamp stamping.parse_db(db.rs:579) is line-oriented (one statement per line). A packed one-linerrecord(...){field(...)}silently drops its fields..dbcannot load longin/longout/mbbi/mbbo/table/ndarray/generic — the twoTODO(follow-up)markers in the codebase (types.rs:45, db.rs:546). Those record types exist only via the builder/handle APIs.
Typed handle layer (pv.rs)
PvScalar (pv.rs:85) is implemented for f64/bool/i32/String and
(added by the value-types work, commit 0819a18) ScalarValue. Each impl overrides from_decoded to dodge the
truthy-first bug in convert::decoded_to_scalar_value (convert.rs:122 checks
bool before numerics, so any nonzero numeric becomes Bool — documented at
pv.rs:92–103).
A handle is Arc<PvShared{name, Mutex<PvState>}> where PvState is
Pending(record + validator + scan + calc) or Bound(Arc<SimplePvStore>).
Builder methods mutate the pending record and warn + no-op if already
bound — this is the "attach before serving" rule surfaced in the Python API
docs. ServeBuilder::build (pva_server.rs:788) drains each handle's parts
into the classic builder, builds, then flips handles to Bound.
Pv::attach/RunningServer::pv mint handles to existing records and refuse
payload shapes that don't match the requested type (regression test at
pv.rs:1154).
Callbacks
| Mechanism | When it runs | Can reject? | Where registered |
|---|---|---|---|
PUT validator (Pv::on_put) | before apply | yes (Err rejects on the wire) | store.set_validator |
on_put (classic builder) | after apply, detached tokio::spawn | no (fire-and-forget) | SimplePvStore.on_put |
scan | interval task calling store.set_value | n/a | spawned in PvaServer::run |
calc/link | evaluate_links after any input changes | n/a | LinkDef list |
Alarms and deadbands
- Alarm computation is
NtScalar::update_alarm_from_value(spvirit-types/src/lib.rs:285), invoked only whencompute_alarmsis true — andcompute_alarmsdefaults tofalse(server.rs:55). - Dual alarm-limit fields:
NtScalarhasalarm_low/high/lolo/hihi(Option<f64>— what the alarm engine reads) andvalue_alarm_*_limit(f64— the NT wire metadata). The.dbparser sets both;Pv::alarm_limits()(pv.rs:329) sets only the wire fields, so handle-API alarm limits do not drive server-side severity computation. Known inconsistency — fix or document before it bites a user. - MDEL (monitor deadband):
should_post_update(simple_store.rs:545) suppresses the post (not the store) when the record is a numeric scalar, MDEL > 0, severity unchanged, and the delta is under MDEL. ADEL is parsed and exposed via field access but not wired into any posting logic (pv.rs:309 comment).
Known gaps / gotchas (beyond those above)
- Timestamps are load-bearing. Missing/epoch-0 timestamps get stamped at
encode time, which breaks monitor deltas and is rejected by the EPICS
Archiver Appliance. Mutation paths stamp timestamps (
set_scalar_valuetypes.rs:430–448,set_array_value/set_nt_payloadtypes.rs:848–960);stamp_missing_timestamps(types.rs:387) is called on store insert (simple_store.rs:74, 109) so static/.db-loaded records are stamped too (see chapter 08). - PUT to
Genericis not wired.RecordInstance::apply_put(apply.rs:639) returnsfalseforGenericwithout looking at the PUT body.NtTable/NtNdArraydispatch toapply_table_put/apply_ndarray_put(apply.rs:609–610) and are writable over the wire;Genericis writable only viaput_nt. - CANCEL_REQUEST is unimplemented (returns an error message); some clients use it. ACL_CHANGE/MESSAGE/MULTIPLE_DATA/ORIGIN_TAG and Op 14/16 likewise return errors.
group.rs::race_all(group.rs:575) polls members in vec order — first ready wins, lower indexes favoured; not starvation-proof.- Default
conn_timeoutis ~64000 s (~17.8 h); the doc comment rounds it to 18 h (pva_server.rs:513). - The beacon change counter only increments on protocol PUTs, not internal scan/set writes (handler.rs:108, 404).
Tests and examples
Tests are inline per-module; the biggest suites are in simple_store.rs
(MDEL, timestamps, put/subscribe, all 12 array element types, put_nt, validator
rejection), pv.rs (constructors, attach guards, ScalarValue preservation) and
pva_server.rs (builder wiring, db_string, links, serve/bind). Run:
cargo test -p spvirit-server.
19 runnable examples under spvirit-server/examples/ — from
simple_server.rs up to snake.rs (a Snake game over PVAccess). The
custom_pvstore / multi_source / wildcard_source / json_source /
aggregate_source / passthrough_source / rpc_source set demonstrates the
Source trait patterns; mailbox.rs is the p4p SharedPV equivalent.
spvirit-client & spvirit-tools — Client Library and CLI Tools
spvirit-client
Library-only crate (~4,900 LOC). Deps: spvirit-types, spvirit-codec,
tokio, serde_json, dns-lookup, get_if_addrs, socket2, chrono.
No TLS crate — TLS is the biggest known gap.
| File | ~LOC | Purpose |
|---|---|---|
pva_client.rs | 970 | High-level API: PvaClient, PvaClientBuilder, PvaChannel (streaming PUT), monitor loop, pvinfo |
search.rs | 1233 | UDP broadcast/multicast search + discovery, TCP name-server search, resolve_pv_server, EPICS env vars |
pvlist.rs | 766 | PV-name listing with 4 fallback strategies (__pvlist, GET_FIELD, server RPC, server GET) |
put_encode.rs | 673 | JSON→PVD PUT payload encoder (bitset partial encoding, scalars/arrays/unions/variants) |
client.rs | 311 | Low-level channel lifecycle: establish_channel, pvget/pvget_fields |
format.rs | 521 | Output rendering (text/JSON), NT metadata extraction, NTTable formatting |
types.rs | 88 | PvOptions, PvGetResult, PvMonitorEvent, PvGetError |
transport.rs | 61 | read_packet / read_until framed-packet readers |
auth.rs | 25 | AuthNZ user/host resolution (options → env → "unknown") |
Discovery and search (search.rs)
resolve_pv_server (search.rs:749) is the entry point: an explicit
server_addr short-circuits; otherwise all strategies run concurrently in a
JoinSet (one TCP name-server search per configured server + one UDP search),
first success wins, rest aborted.
- Targets: explicit
--search-addroverrides everything; otherwiseEPICS_PVA_ADDR_LISTentries + auto-broadcast targets (per-interface directed broadcast + IPv4 multicast224.0.0.128+ IPv6ff02::42:1— multicast added because Docker overlay networks may block broadcast). - Retransmit schedule: 100/500/1000/2000 ms within the overall timeout.
- Ephemeral source port is intentional (search.rs:376): binding the client to 5076 with SO_REUSEPORT loops packets back to the sender on Linux. Do not "fix" this.
- SO_REUSEADDR is Unix-only (search.rs:321): deliberately skipped on Windows where the semantics are unsafe. Also do not "fix".
Operations
- Channel setup (
establish_channel, client.rs:56): TCP connect → learn version + byte order from the first packets → client validation (authnz "ca") → wait for ConnectionValidated → CREATE_CHANNEL (cid hardcoded to 1 — fine for one-shot connections, a hazard if you ever multiplex). - GET: GET INIT (subcmd 0x08, fixed 6-byte "all fields" pvRequest or
encode_pv_request(fields)) → introspection from INIT → GET DATA →decode_with_field_desc. Result carriesvalue: DecodedValueplus raw PVA and PVD bytes and the introspection. - PUT: defaults to field
["value"]; value isimpl Into<serde_json::Value>, encoded byput_encode.rs(which does support nested structures, unions, variants, structure-arrays — the README's "structured put payloads" caveat means not fully surfaced/battle-tested, not absent).open_put_channelreturns aPvaChannelfor streaming puts (reuses introspection, echo keepalive after 10 s idle, background reader aborted on Drop). - MONITOR: INIT → START =
0x44(START|GET). CallbackFnMut(&DecodedValue) -> ControlFlow<()>;Breaksends best-effort DESTROY. Read timeouts are non-fatal (continue); 10 s echo keepalive. Pipelining (MonitorOptions::pipelined(n)) encodespipeline=true,queueSize=Nin the pvRequest and appends queueSize to the INIT body and sets bit 0x80 on the INIT subcmd; ACKs at queueSize/2. Critical invariant (pva_client.rs:548–552): never set 0x80 on the START message — on non-INIT monitor messages 0x80 means "ACK with u32 body", and getting this wrong makes pvxs/Java drop the TCP connection. - INFO: GET_FIELD (cmd 0x11) →
StructureDesc.
Environment variables
| Variable | Effect |
|---|---|
EPICS_PVA_ADDR_LIST | extra search targets |
EPICS_PVA_AUTO_ADDR_LIST | YES/NO auto-broadcast (default on) |
EPICS_PVA_NAME_SERVERS | TCP name servers (host[:port], port defaults 5075) |
EPICS_PVA_ENABLE_GET_FIELD_FALLBACK | opt-in pvlist GET_FIELD strategy |
PVA_AUTHNZ_USER / USER / LOGNAME / USERNAME | authnz user |
PVA_AUTHNZ_HOST / HOSTNAME / HOST / COMPUTERNAME | authnz host |
Note: standard EPICS_PVA_SERVER_PORT / EPICS_PVA_BROADCAST_PORT are not
read by the client — ports come from builder/CLI defaults (5075/5076).
Client gotchas
- Epoch disambiguation heuristic (format.rs:98):
secondsPastEpochis interpreted as UNIX or EPICS-1990 epoch by picking whichever is closer to "now" — fragile for historical or far-future timestamps. - pvlist's server-GET strategy scrapes ASCII candidates from raw bytes with a denylist filter — heuristic, can false-positive.
spinfoworks around servers that crash on an empty GET_FIELD field name by retrying without the wire field, then falling back to pvget.- No automatic reconnect anywhere; a single
timeout(default 5 s) applies to connect and every read.
spvirit-tools
Binaries + a thin lib re-exporting the client and server crates. Features:
default = ["client", "server", "tui"]; tui pulls ratatui 0.29 +
color-eyre. Source files are named spvirit_*.rs but the installed binaries
are the sp* names (see the [[bin]] table in Cargo.toml).
All client tools share CommonClientArgs (src/spvirit_client/cli.rs:25) —
timeout/server/search-addr/name-server/ports/debug/authnz/fields flags — via
the argparse crate, and block_on a manually built tokio runtime.
| Binary | LOC | What it is / what it exercises |
|---|---|---|
spget | 56 | One-shot GET; format_output, --raw hex dumps |
spput | 316 | PUT; default "EPICS-base-style" full flow (GET → PUT INIT → get-put probe → PUT → DESTROY_REQUEST → GET), auto-fallback to simple flow; PV=VALUE syntax for negative numbers |
spmonitor | 278 | Multi-PV monitor (JoinSet); --raw, --json, --pipeline N |
spinfo | 192 | Introspection with the 3-level fallback chain |
splist | 112 | Server discovery (GUID + addr) or PV listing via pvlist_with_fallback |
spsine | 102 | Streaming-PUT sine generator (open_put_channel showcase) |
spget_compare | 324 | Offline diagnostic: byte-compares captured frames against local encoder output; no network |
spexplore | 1414 | ratatui TUI: servers→PVs→details panes, chart view, background worker thread over std::sync::mpsc |
spsearch | 1094 | ratatui TUI: passive search-traffic sniffer; decodes frames directly with PvaPacket |
spserver | 4185 | Full PVA server binary: .db loading, hot-reload, beacons, MDEL, __pvlist/discovery modes; record logic comes from the spvirit-server crate |
spdodeca | 1179 | Self-contained single-PV server streaming a rotating dodecahedron as NTNDArray (does not use spvirit-server) |
sptable | ~1200 | ratatui TUI spreadsheet IOC. Rows are dynamically added PVs: 12 scalar types, arrays, NTEnum, NTTable. Modal a wizard plus a vim-style : command line (:add/:set/:del/:mv/:ro/:rw/:anim/:stop/:source, shorthands, :help). Bash-style pattern expansion (RING:BPM{01..99}, products) for bulk ops. Animation generators (sine/ramp/triangle/square/noise/walk/count, enum cycle) driven by a server-side tick (--rate, default 10 Hz). |
sptable command reference
Mirrors help_text() in spvirit_table/main.rs — keep in sync.
| Verb | Shorthand | Args | Effect |
|---|---|---|---|
add | a | <name> <type> [ro|rw] <value> | add PV(s) |
set | s | <name> <value> | set value (choice name or index for enum) |
del | d | [name] | delete (blank = selected row) |
rename | mv | <old> <new> | rename (scalar/enum) |
| — | ro/rw | <name> | set advertised access |
anim | — | <name> <gen> [k=v ...] | animate |
stop | — | [name] | stop animation (blank = selected) |
source | so | <file> | run a script of commands |
write | w | <file> | dump the session as a loadable script |
rate | — | <hz> | retune the animation tick live (also --rate at startup) |
help/quit | h/q | show help / quit |
write/w and source/so are inverses: :w <file> writes a rate
line, one add per PV (values read back so edits and table contents
survive), and one anim per animated PV — all in row order; :source <file> replays it to rebuild the session.
Typespec aliases: bool int8 int16 int32(int) int64(long) uint8 uint16 uint32 uint64 float(f32) double(f64) string(s); arrays via int32[]
suffix; plus enum and table.
Pattern forms (bash-brace style, expanded before every name verb):
{1..8}, {8..1} (descending), {0..100..10} (step), {01..12}
(zero-padded), {A,B,C} (list), and products like S{1..4}:{A,B}.
Generators: sine ramp triangle square noise walk count for scalars,
cycle for enums — e.g. :anim RING:BPM{01..99} noise min=-1 max=1. Each
generator only accepts the params it uses (:help lists them with defaults);
a param it doesn't use is rejected — e.g. sine takes amp offset period phase (range is amp±offset), not min/max.
Value forms: enum accepts a choice name or index (OFF, ON, TRIP, or
1); table accepts per-column id:i32=1,2,3 x:f64=0.5,1.5.
Known spserver limitations: ACL_CHANGE/MESSAGE/MULTIPLE_DATA/CANCEL_REQUEST/ ORIGIN_TAG return "not supported"; NtTable/NtNdArray DOL output links are "no-op for now" (spvirit_server.rs:2700, 2730).
Tests
- spvirit-client: ~39 inline unit tests (search target math, addr-list parsing, encode/decode round-trips, builder defaults, put_encode bitsets). No integration dir — integration coverage lives in spvirit-tools.
- spvirit-tools
tests/: ~33 test functions in two harness styles:tests/protocol/— in-process wire testing (frame_harness.rsspawns workspace binaries;scenario_harness.rsgives connect/handshake/get/put helpers). Used byspvirit_protocol_*,spvirit_pvlist,spvirit_nt_*,spvirit_monitor_*,ioc_fields,pv_handle_api.tests/interop/— external implementations (p4p/pvxs, EPICS Base, PVAccessJava), env-gated so they skip unless e.g.PVA_TEST_P4P=1and the external server is installed. See chapter 06.
demo/ directory (repo root, gitignored)
Archiver Appliance demo: archiver_demo_server.py (spvirit-py based, 18 PVs
of every type incl. NTEnum/NTTable/NTNDArray), archiver_demo_gen.py
(stdlib .db animator for spserver hot-reload), archiver_demo.db,
Dockerfile (builds the wheel + runs the server). docker_compose.yml is a
0-byte placeholder. demo/README.md documents both run modes and warns that
.db-reloaded arrays get a 1990-epoch timestamp the Archiver rejects.
spvirit-py — Python Bindings
PyO3 bindings exposing the client, server, typed PV handles, NT payloads,
dynamic sources, and the low-level channel/codec to Python. Distributed on
PyPI as spvirit; the complete user guide is spvirit-py/README.md (~32 KB,
the primary user doc). This chapter covers the internals.
Packaging
Cargo.toml: packagespvirit-py, versioned independently of the Rust crates (0.1.15 vs 0.1.18 — see chapter 07).[lib] name = "spvirit",crate-type = ["cdylib"]. pyo3 0.24 (extension-module,abi3-py39— one wheel for Python 3.9+),pyo3-async-runtimeshard-pinned =0.24.0, tokio 1.47.pyproject.toml: maturin backend, distribution namespvirit, versiondynamic(from Cargo.toml).- There is no Python source package — the whole API is the compiled
module plus two Rust-registered submodules
spvirit.codecandspvirit.lowlevel(injected intosys.modulesat import). - No
.pyitype stubs — a known gap for a binding whose selling point is a typed API. - Stale artifacts present on disk but gitignored, not committed:
dist/spvirit-0.1.9.tar.gzand.venv/(both under.gitignore; the build/test commands assume.venv/exists locally).
Dev loop:
cd C:\spvirit\spvirit-py
.\.venv\Scripts\maturin.exe develop # debug build, importable in the venv
.\.venv\Scripts\python.exe tests\test_pv_handles.py
Module map (spvirit-py/src/, ~6,300 LOC)
| File | LOC | Purpose |
|---|---|---|
lib.rs | 81 | #[pymodule] — the authoritative index of the public surface; registers classes, 16 factory functions, submodules; initializes the runtime bridge |
runtime.rs | 64 | Shared Tokio runtime (LazyLock); block_on_py (releases GIL, re-entrancy aware via block_in_place); future_into_py (asyncio bridge) |
errors.rs | 138 | Exception hierarchy under SpviritError; TimeoutError/IoError dual-inherit builtins |
convert.rs | 605 | Value conversion layer, including the typed (strict) coercion layer (see below) |
pv.rs | 997 | PyPv + PvKind, all factories, on_put/scan/calc bridging — the central file |
server.rs | 835 | PyServerBuilder, PyServer, PyStore |
client.rs | 595 | PyClientBuilder, PyClient, PySubscription, discovery |
nt.rs | 740 | NT wrapper classes, NtPayload↔Python bridging |
source.rs | 617 | Python-defined dynamic Sources: PyPvInfo, PyNotifier, PySourceAdapter, sync/async bridging |
channel.rs | 635 | spvirit.lowlevel.Channel — persistent single-PV TCP connection |
codec.rs | 492 | spvirit.codec — FieldDesc/StructureDesc wrappers, packet decode helpers |
discovery.rs | 300 | spvirit.lowlevel search/discover/pvlist functions |
packet.rs | 172 | spvirit.lowlevel.Packet — owned PVA frame |
Threading model (read before touching)
Three design comments are the real documentation — read them first:
runtime.rs:29–42— one shared multi-thread Tokio runtime.block_on_pyreleases the GIL and detects being already on a runtime worker (usesblock_in_place), so a Python callback running inside the runtime can callpv.set()/client ops without deadlock. Covered bytest_on_put_can_set_other_pvsandtest_client_usable_inside_callbacks.pv.rs:370–382— scan closures run synchronously inside the async scan task and therefore cannot block-on other PV reads;None/raised exception falls back to the closure's cached last value.source.rs:319–326—async defsource methods are submitted to a dedicated long-lived asyncio event loop on its own Python thread (run_coroutine_threadsafe), then.result()blocks with the GIL released — this avoids the nested-run_until_completedeadlock.
The "sync-only for phase 1" headers on server.rs:1/client.rs:1 are
historical — PyPv and Channel have async variants now; top-level
Server/Client ops remain blocking. Reconcile the comments when convenient.
The API surface, mapped to Rust
PvKind(pv.rs:29):F64/Bool/I32/Str/Arraywrapping the server crate'sPv<f64>/Pv<bool>/Pv<i32>/Pv<String>/PvArray, plusTyped(Pv<ScalarValue>, TypeCode)— a dynamically typed scalar covering all twelve NTScalar wire types, backingspvirit.scalar()and anylong/unsigned handle minted byserver.pv().- Factories:
ai/ao/bi/bo/string_in/string_out/longin/longoutgenerated by thepv_ctor!macro (pv.rs:604), each with keyword-onlyunits/prec/desc/adel/mdel/drive_limits/alarm_limits;mbbi/mbbohand-written;waveform/aai/aaobuildPvKind::Arrayand accept a keyword-onlytype=for the element type;scalar(name, initial, *, type, writable=False, **opts)covers all twelve NTScalar wire types viaPvKind::Typed;calcbridgescallback(list[float]) -> float;pv()infers record type from the initial value (bool checked before int —isinstance(True, int)is True) or, withtype=, picks the wire type explicitly. - Callbacks:
on_put(pv, value)runs before apply; returningFalseor raising rejects the PUT on the wire; handle-drivenpv.set()bypasses it.scanworks as call or decorator; must be attached before serving (afterwards is a silent no-op — the core only logs a tracing warning).calcexceptions/non-float returns post0.0(asymmetric with scan's cache fallback — documented in the README). Server: primary constructorServer(pvs=[...], sources=[...], **kwargs);run()blocks,start()spawns astd::threadrunningRUNTIME.block_on(server.run()).server.pv(name)sniffs the stored NT payload to mint a typed handle — it attaches to any served record, includinglong/unsigned/float-distinct ones, which now mint a dynamically typedPvKind::Typedhandle (server.rs:583); it still raisesKeyErrorfor NTTable/NTNDArray/generic-structure records, which have no handle representation (useStorefor those).Store: name-keyed runtime access (get_value/get_nt/set_value/ set_array_value/put_nt/pv_names), all throughblock_on_py.set_value/set_array_value/put_ntcoerce strictly to the record's existing wire type and never retype a scalar or scalar-array record (put_ntonNtTable/NtNdArrayreplaces the payload wholesale instead).Client:get/put/monitor/info/pvlist/subscribe;PySubscriptionruns on a spawned task, hasis_active/error/close, context-manager support, and aDropthat aborts the task.- NT classes:
NtScalar/NtScalarArrayconstructible with an optionaltype=picking the wire value type explicitly;NtTable(columns, *, labels=None, types=None, descriptor=None)andNtNdArray(value, dims, *, type=None)are now constructible too (previously read-only, returned only byStore.get_nt).EnumandGenericpayloads cross the boundary as plain dicts (nt.rs:709, 716). - Dynamic sources: any duck-typed object with
claim/get/put/names(+optionalrpc/subscribe/on_start), sync or async.PvInfo.nt_scalar("double", writable)declares precise wire types via type strings — parsed byparse_type_code, now centralized inconvert.rs(source.rs:42 imports it) and shared with the rest of the typed API.
The conversion layer
convert.rs has two parallel paths:
- Inference path (unchanged, used when no
type=is given): full-fidelity Rust → Python (every width preserved;U8arrays becomebytes), but lossy Python → Rust —py_to_scalar(convert.rs:199):bool→Bool,int→I64,float→F64,str→Str;py_to_scalar_array(convert.rs:221):bytes→U8, empty list →F64, otherwise sniffs the first element. - Typed (strict) path (convert.rs:411 onward —
py_to_scalar_typed,py_to_scalar_array_typed,coerce_scalar_value,coerce_scalar_array_value): used whenever atype=/types=string is given, or when coercing against an existing record's wire type. Every Python value is checked strictly against the requestedTypeCode: out-of-range raisesOverflowError, wrong-kind raisesTypeError, an unrecognized type string raisesValueError. This is what makes all twelve NTScalar wire types (byte/short/int32/ubyte/ushort/uint32/uint64/ float32/...) reachable from Python — seespvirit.scalar(), thetype=/types=kwargs across NT classes, factories, andServerBuilder, and chapter 08 for how this landed.
Tests and examples
- No Rust unit tests in this crate (it's a cdylib); the Rust-side tests
for handle plumbing live in
spvirit-server(cargo test -p spvirit-server pv::). - Python tests:
tests/test_pv_handles.py(422 LOC) andtests/test_value_types.py(324 LOC, covers the typed-conversion layer:NtScalar/NtScalarArray/NtTable/NtNdArraytype=/types=,spvirit.scalar(),server.pv()on long/unsigned records, strict coercion errors) — plain-assert scripts, not pytest; functions namedtest_*collected by amain()loop. Each server test uses a unique port pair (test_pv_handles.py: 15075–15206;test_value_types.py: 16060–16081, within the reserved 16060–16099 range). Run directly with the venv's python aftermaturin develop. - Examples: 24 scripts under
spvirit-py/examples/— start withdemo_pv_handles.py(typed handles),demo_server.py, thedemo_source_*.pyfamily (dynamic sources),demo_channel*.py(low-level),demo_10k_farm.py/demo_stress.py(scale).
Testing Guide
The four layers
| Layer | Where | Run with |
|---|---|---|
| Rust unit tests | inline #[cfg(test)] modules in every crate's src/ | cargo test --all |
| In-process protocol/integration tests | spvirit-tools/tests/ (19 test files + shared harnesses, ~33 test fns) | cargo test -p spvirit-tools |
| Cross-implementation interop tests | spvirit-tools/tests/interop_*.rs + tests/interop/ | env-gated; see below |
| Python tests | spvirit-py/tests/test_pv_handles.py + test_value_types.py | run the file directly with the venv python |
There are no benchmarks (no benches/, no criterion) and no
golden-file/packet-capture fixtures — codec tests are inline-bytes and
round-trip based. Both are worth adding.
Rust unit tests
Every crate keeps tests at the bottom of each source file. Notable suites:
spvirit-codec: spvd_encode (18), spvirit_encode (20), spvirit_state (15 — these double as the state-machine spec), epics_decode (9), spvd_decode (7).spvirit-server: simple_store (MDEL, timestamps, all 12 array element types, put_nt, validator rejection), pv (constructors, attach guards), pva_server (builder wiring, links), monitor (delta-frame semantics), db, record_fields, group.spvirit-client: ~39 tests (search target math, parsing, round-trips).
In-process protocol tests (spvirit-tools/tests/)
Two harnesses under tests/protocol/:
frame_harness.rs—TestServer/TestSession: spawns actual workspace binaries (locatestarget/<profile>/) and speaks raw frames.scenario_harness.rs—ScenarioSession: higher-level connect/handshake/get/put helpers.
Used by spvirit_protocol_* (codec, lifecycle, unsupported-command
handling), spvirit_pvlist, spvirit_nt_codec/spvirit_nt_lifecycle,
spvirit_record_array, spvirit_monitor_fields (nested pvRequest
selection), spvirit_monitor_pipeline (flow control), ioc_fields
(PV.FIELD/FIELD$), pv_handle_api, spvirit_search_resilience,
spvirit_get (incl. read-only/simulation PUT authorization).
Interop tests (against real EPICS implementations)
tests/interop/harness.rs provides ProcessGuard, LocalServerFixture,
free-port helpers, and sets EPICS_PVA_ADDR_LIST=127.0.0.1,
EPICS_PVA_AUTO_ADDR_LIST=NO, EPICS_PVA_BROADCAST_PORT for hermetic runs.
All interop tests are env-gated and skip silently unless enabled:
| Suite | Needs | Enable |
|---|---|---|
| p4p (pvxs) | pip install p4p | PVA_TEST_P4P=1, P4P_PROVIDER_CMD → tests/interop/p4p_server.py, P4P_TEST_SERVER=127.0.0.1:5075 |
| EPICS Base | EPICS Base install | its own enable vars (see interop_epics_base.rs) |
| PVAccessJava | Gradle project | see interop_pvaccess_java.rs |
| Generic external server | any PVA server | PVA_TEST_SERVER, PVA_TEST_PV, PVA_TEST_MONITOR, PVA_TEST_* addr/port vars |
CI runs the p4p matrix on every push/PR (see chapter 07). The command:
PVA_TEST_P4P=1 P4P_PROVIDER_CMD="python spvirit-tools/tests/interop/p4p_server.py" \
cargo test -p spvirit-tools --test interop_tool_matrix -- p4p_provider_matrix
Python tests
Plain-assert style, deliberately not pytest: test_* functions collected by
a main() loop at the bottom of the file. Conventions:
- Every test that starts a
Serveruses its own unique port pair (test_pv_handles.py: 15075–15206;test_value_types.py: 16060–16081, within its reserved 16060–16099 range). Pick unused ranges for new files. - Build first:
.\.venv\Scripts\maturin.exe develop(debug is fine). - Run:
.\.venv\Scripts\python.exe tests\test_pv_handles.py→ expectALL OK.
Conventions for new work
- TDD is the house style: write the failing test first, verify it fails, implement, verify it passes, commit per task. Follow the same rhythm.
- Timestamps in tests: always set explicit
NtTimeStamps on payloads you assert against — aNonetimestamp is stamped at encode time and makes monitor-delta assertions flaky (see chapter 02). - When touching the codec, remember the four duplicated size codecs and the absence of a capture corpus — add a round-trip test in the same file as the change, and consider checking real captures from pvxs/p4p if the change is wire-visible.
Build, CI, and Release
Building
cargo build --release # whole workspace
cargo test --all # all Rust tests
Python bindings (from spvirit-py/, venv assumed at .venv/):
.\.venv\Scripts\maturin.exe develop # dev build into the venv
maturin build --release # wheel → target/wheels/
Facts a new team should know:
- All crates are edition 2024; there is no
rust-toolchain.toml, no MSRV declaration, no committed.cargo/config.toml(it's gitignored for local patch overrides), and no clippy/rustfmt config — CI usesdtolnay/rust-toolchain@stableand formatting is by convention (cargo fmtbefore committing; history shows manualstyle:commits). - No
[workspace.dependencies]— each crate declares its own deps, so versions can drift; check when bumping shared deps like tokio. - Feature flags exist only in
spvirit-tools:default = ["client","server","tui"]; each binary declaresrequired-features. target/package/spvirit-*-0.1.x/directories arecargo packageartifacts — never edit those copies.
CI (.github/workflows/ci.yml)
Runs on push and PR to main, two jobs on ubuntu-latest:
- build-and-test —
cargo build --release+cargo test --all. - p4p-interop — installs
p4p(Python 3.12), builds, runs the p4p provider matrix (interop_tool_matrix -- p4p_provider_matrix) withPVA_TEST_P4P=1etc.
There is no clippy or rustfmt gate in CI. If you want one, add it — but be aware the codebase has never been held to a CI lint bar.
Releases
Two independent release tracks:
Rust crates (manual)
- All five Rust crates are versioned in lockstep (currently 0.1.18) with path-deps pinned to the same version.
- Published to crates.io manually via
cargo-release(thechore: Releasecommits) — there is no workflow for this; no release-plz. - History includes a version-numbering hiccup (
chore: bump versions past the unrecorded 0.1.15 / 0.1.12 releases) — when bumping, keep crate versions and path-dep pins consistent, and verify what's actually on crates.io before choosing the next number.
Python wheels (automated)
spvirit-pyversions independently (currently 0.1.15); the wheel version comes from itsCargo.tomlvia maturindynamic = ["version"].- Release trigger: push a tag matching
spvirit-py-v*→.github/workflows/release-python.ymlbuilds wheels on a 5-platform matrix (linux x86_64 + aarch64, windows x64, macOS x86_64 + aarch64) plus an sdist, and publishes to PyPI via trusted publishing (OIDC, environmentpypi) — only on the tag push, not on manual dispatch. - PyPI package name:
spvirit.
Release checklist (suggested)
- Working tree clean;
cargo test --allgreen; Python suite green. - Rust: bump all five crate versions + path-dep pins together;
cargo release(or manualcargo publishin dependency order: types → codec → client/server → tools). - Python: bump
spvirit-py/Cargo.tomlversion, commit, tagspvirit-py-vX.Y.Z, push the tag, watch the workflow. - Update the README's version references if any.
Conventions
- Conventional Commits:
feat(py):,fix(server):,docs:,chore: Release,!for breaking changes. Merges via GitHub PRs. - Planning workflow: substantial features get a design spec and a task-by-task
TDD plan (checkbox steps, exact commands, per-task commit messages) before
any code is written. Those working documents stay local and are not
committed — the durable record is
docs/book/src/06-dev-guide/, the commit history, and the tests. - README has a GenAI Usage Log table documenting which parts were AI-assisted — keep it updated (transparency requirement).
- License: BSD-3-Clause, ISIS Neutron and Muon Source.
- No CONTRIBUTING.md yet; contribution guidance is informal ("PRs welcome").
Operational notes
- Default ports: TCP 5075 (data), UDP 5076 (search/beacon).
- The server binds UDP 5076 with SO_REUSEADDR/SO_REUSEPORT so it can coexist with other PVA servers on the host.
- Primary dev environment has been Windows (PowerShell commands,
.venv\Scripts\...paths); CI is Linux. Watch for path and socket-semantics differences (the client deliberately treats SO_REUSEADDR as Unix-only). - Interop test matrix validated against EPICS Base, p4p/pvxs, and PVAccessJava.
Current State, In-Flight Work, and Roadmap
Snapshot taken 2026-07-16, revised 2026-07-17 after Effort B landed. Reconcile against
git log/git statusbefore acting on anything here — this chapter goes stale fastest.
Repository state at handover
- Branch
main, 15 commits ahead oforigin/main(unpushed, as of this revision) — the design spec/plan for Python NT value-type selection, the NTTable-metadata/timestamp fix, and the full Effort B implementation (7c5bc9bthroughd85473b, latestfix(py): final-review fixes — strict types= key validation, doc accuracy). - Working tree is clean — the three files that were mid-edit at the
2026-07-16 snapshot (
spvirit-codec/src/spvd_encode.rs,spvirit-server/src/simple_store.rs,spvirit-server/src/types.rs) have since been committed as part of Effort A below. There are no uncommitted changes to reconcile.
Effort A — NTTable metadata + store-entry timestamps (committed: 0ac87d5)
Purpose: make static/NTTable PVs archivable by the EPICS Archiver Appliance
(it rejects epoch-0 events and NPEs on structures without a top-level
timeStamp).
spvd_encode.rs:nt_table_descnow includesdescriptor,alarm,timeStampfields;encode_nt_table_fullencodes them (defaulting whenNone); new round-trip testnt_table_wire_format_carries_metadata. Encode order must match descriptor field order — that's the invariant.types.rs: newRecordInstance::stamp_missing_timestamps()fills missing timestamps per payload family (NdArray stampsdata_time_stamptoo; Generic skipped).simple_store.rs: calls it inSimplePvStore::newandinsert, plus a test asserting all record families getseconds_past_epoch > 0on store entry. Purely additive; caller-supplied timestamps are preserved.
Effort B — Python NT value-type selection (landed)
Delivered as a 10-task TDD plan with per-task commits.
Goal (achieved): let Python select any of the twelve NTScalar wire types
instead of collapsing int→I64/float→F64. Architecture: shared
type-string parser + strict coercion layer in spvirit-py/src/convert.rs
(parse_scalar_type, py_to_scalar_typed, py_to_scalar_array_typed,
coerce_scalar_value, coerce_scalar_array_value); keyword-only type=/
types= params across NT classes, factories, and ServerBuilder, and a new
spvirit.scalar() factory backed by PvKind::Typed(Pv<ScalarValue>, TypeCode); store put paths coerce to the record's existing wire type.
Errors: ValueError (unknown type string), OverflowError (out of range),
TypeError (wrong kind) — matches spvirit-py/README.md, the authoritative
user-facing doc.
Status: all 10 tasks are committed (git log 0819a18..d85473b).
spvirit-py/tests/test_value_types.py (324 LOC, ports 16060–16081) now
exists and exercises the full surface: NtScalar/NtScalarArray
type=/.value_type, NtTable/NtNdArray constructors, spvirit.scalar(),
server.pv() on long/unsigned records (no more KeyError), waveform/
aai/aao/pv() with type=, ServerBuilder type=/types= kwargs, and
Store.set_value/set_array_value/put_nt strict coercion. Build:
.\.venv\Scripts\maturin.exe develop; tests:
cargo test -p spvirit-server pv:: and
.\.venv\Scripts\python.exe tests\test_value_types.py.
Known gaps and latent bugs (triage list)
Cross-referenced from the per-crate chapters.
Protocol/codec
- No TLS anywhere (top roadmap item in README).
- No segmentation reassembly in the codec (consumers roll their own).
- Monitor bitset overrun ordering is heuristic (three variants + scoring).
- No packet-capture regression corpus; four duplicated size codecs.
- Array decode caps silently truncate; string/struct truncation can desync.
Server
- CANCEL_REQUEST unimplemented (some clients send it).
- PUT to
Genericnot wired intoRecordInstance::apply_put(onlyput_ntworks);NtTable/NtNdArrayPUTs are wired and restamp like any other record. .dbparser: one-statement-per-line only; cannot load longin/longout/mbbi/mbbo/table/ndarray/generic (the repo's only twoTODO(follow-up)markers:types.rs:45,db.rs:546).Pv::alarm_limits()sets only the wire metadata fields, not the fields the alarm engine reads —.alarm_limits()+compute_alarms(true)does not auto-alarm.compute_alarmsdefaults to false.- ADEL parsed but not enforced; beacon change counter only bumps on protocol PUTs.
Client/tools
- Structured puts not fully surfaced (README caveat;
put_encode.rsis more capable than the high-level API exposes). - Epoch disambiguation heuristic in
format.rs(UNIX vs 1990 epoch by closeness to now). - Hardcoded cid=1/ioid=1 in single-shot paths — hazard if ever multiplexed.
- spserver: NtTable/NtNdArray DOL links are no-ops;
demo/docker_compose.ymlis an empty placeholder.
Python
- No
.pyitype stubs. - Stale artifacts present but gitignored (not committed):
spvirit-py/dist/spvirit-0.1.9.tar.gz,spvirit-py/.venv/. - "sync-only for phase 1" file headers are outdated.
The type-collapsing limitation— fixed by Effort B: all twelve NTScalar wire types are now selectable from Python viatype=/types=andspvirit.scalar(), with strict coercion. Remaining Python-side gaps:on_put/scanstill unsupported on array PVs; widenedbyte/short/inthandles (the plainintPvKind) are not range-checked on write — onlyspvirit.scalar(type=...)/store.set_valueenforce strict range checks on those types (see the README's "Widenedinthandles are not range-checked" caveat).
Process
- No clippy/rustfmt CI gate; no MSRV; no CONTRIBUTING.md; crates.io releases are manual with a history of version-numbering slips.
Roadmap (from README + observed direction)
- TLS support in client (and eventually server).
- Structured put payloads surfaced in the high-level client API.
- More complete softIOC behaviours and record processing in the server
(record types in
.db, table/ndarray PUT, CANCEL_REQUEST). Finish the Python value-types work (Effort B)— done; see above.- Quality infrastructure: packet-capture regression corpus, benchmarks,
lint gate in CI,
.pyistubs.