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: i32 + 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 (i32) + 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 aformsub-structure whosechoiceslist is the seven standard display-format names (Default,String,Binary,Decimal,Hex,Exponential,Engineering— theSTANDARD_FORM_CHOICESstatic inspvirit-types). It does not carryZNAM/ONAM; thosebi/bostate names are stored on the record, not in the NTScalar payload. - 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 = |update: &MonitorUpdate| { println!("{}", update.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 is handed a &MonitorUpdate, not a bare value: update.value
is the decoded delta, and the update also carries which fields changed and
which the server dropped (see Overruns below). It 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(update):
# `update` is a MonitorUpdate: .value plus the .changed / .overrun paths.
global seen
seen += 1
print(f"{seen}: {update.value['value']:.3f}")
return seen < 5 # returning False ends the monitor
client.monitor("SIM:TEMPERATURE", on_update)
The Python callback receives a spvirit.lowlevel.MonitorUpdate too —
update.value is the decoded structure as a dict, so an NTScalar's number is
update.value["value"].
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.
Overruns
Every update carries two bitsets: changed, the fields this delta actually
contains, and overrun, the fields for which the server dropped at least
one earlier update before sending this one. An overrun means you are seeing
the latest value but not every value. Raising the queue depth with
MonitorOptions::pipelined(q), or doing less work in the callback, is the fix:
a pipelined subscription is delivered losslessly, so it is never conflated.
spvirit's own server does not populate the overrun bitset. Under load it conflates a non-pipelined subscriber's queued updates down to the latest value per subscription — dropping the intermediate ones — and still sends an empty overrun bitset, so
has_overrun()staysfalseeven when frames were silently dropped. The bits remain meaningful against servers that do set them (a real EPICS IOC, or pvxs), and pipelining a spvirit subscription avoids the drops entirely. So treat the overrun API as "believe it when it fires," not as a promise that a quiet stream lost nothing.
In Rust, update.changed and update.overrun are the raw bitset bytes;
changed_paths() and overrun_paths() resolve them to dotted field names,
and has_overrun() is the cheap check:
let cb = |update: &MonitorUpdate| {
if update.has_overrun() {
eprintln!("dropped updates for: {}", update.overrun_paths().join(", "));
}
println!("{}", update.value);
ControlFlow::Continue(())
};
In Python the resolution is already done: .changed and .overrun are lists
of dotted paths and .has_overrun is a property.
def on_update(update):
if update.has_overrun:
print("dropped updates for:", update.overrun)
print(update.value["value"])
return True
The path "<whole structure>" appears when bit 0 is set — the server is
reporting the whole value rather than naming individual fields.
From the command line
$ spmonitor SIM:TEMPERATURE
When the server reports overruns — a real EPICS IOC or pvxs, not spvirit's own
server (see the note above) — spmonitor prints them to stderr, one line per
affected update, so they never contaminate the value stream on stdout:
SOME:IOC:PV: overrun on value, alarm.severity
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, _guid) = 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 — the mbbi/mbbo arm
at spvirit-server/src/db.rs:550 (message at db.rs:558). 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 enum PUT branch (RecordInstance::apply_put →
RecordData::NtEnum, spvirit-server/src/apply.rs:631) matches only a bare
integer under value (DecodedValue::Int32/Int64/…), but the NTEnum wire
format nests the index one level deeper as value.index — a sub-structure no
arm matches, so the update is left alone. The code comment there flags this as
Known gap #1. 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 (should_post_update,
spvirit-server/src/simple_store.rs:571).
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:271) — 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:629) 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 pushes_own_updates(&self) -> bool; // default false 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:716). Monitors are driven by
notifier.notify(name, payload) instead.
on_start fires at server start (start()/start_background()/run()),
not at build() — and it shares one ordered list with @builder.on_start
hooks registered on ServerBuilder: whichever was registered first (a
source via add_source, or a hook via @builder.on_start) runs first. A
source's order only decides which source claims a PV name; it plays no
part in this startup sequence. See Lifecycle hooks and
events
in the README for the full picture, including server-wide named events
(post_event/on_event).
Migration note. This is a behaviour change: a Python source's
on_startused to fire duringbuild(). Code that relied on that timing (e.g. publishing an initial value beforebuild()returns) now needs to wait until the server has started 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)
Two delivery paths, and why pushes_own_updates exists. There are two
ways a source's changes reach a monitoring client, and a source must use
exactly one:
- Return a channel from
subscribeand leavepushes_own_updatesat its defaultfalse. When a client starts a monitor, the server calls yoursubscribe, then runs one pump task per PV that drains the channel into the monitor registry — so every value you send reaches the client. This is the path for gateway proxies, group PVs, and any source whose values originate elsewhere. It is what makes a monitor through the gateway deliver ongoing updates, not just the first value. - Push into the monitor registry yourself — the built-in record store and
the IOC engine already call
notify_monitorson every write. Such a source returnspushes_own_updates() -> true, which tells the server not to also pumpsubscribe; pumping a source that already self-notifies would deliver every update twice.
Most custom sources take the first path and never touch pushes_own_updates.
Override it to true only if your source itself drives the monitor registry.
In Python the choice does not arise: subscribe is ignored and the Notifier
is the self-notifying path, so Python sources behave as if pushes_own_updates
were true.
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 thirteen 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 |
spgateway | client, server | p4p-compatible PVAccess gateway |
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 + server | spgateway |
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:236). 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 | 1 | sine frequency |
--rate N | 10 | writes per second |
--amp A | 1 | amplitude |
--offset O | 0 | vertical offset |
--phase RAD | 0 | 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 | 15 | beacon interval; 0 disables beacons |
--beacon-addr IP:PORT | 224.0.0.128:5076 | beacon target (auto-switches to [ff02::42:1]:5076 when --listen-addr is IPv6) |
--conn-timeout SECS | 64000 (~17.8 h) | idle connection timeout; overridable via the EPICS_PVA_CONN_TMO env var |
--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.
Controlling the rotation
Alongside the image, the server serves three writable double scalar PVs
that set the spin rate about each axis, in rad/s:
| PV | Default | Axis |
|---|---|---|
DODECA:SPEED_X | 0.3 | pitch |
DODECA:SPEED_Y | 0.5 | yaw |
DODECA:SPEED_Z | 0.15 | roll |
They share the image PV's prefix — the part before the first : — so
--pv RIG:CAM serves RIG:SPEED_X and so on, not DODECA:SPEED_X. Put
to one and the rotation changes live; monitor one and you see writes from
other clients echoed back.
$ spput DODECA:SPEED_Y 2.0 # spin faster about the vertical axis
DODECA:SPEED_Y OK
$ spput DODECA:SPEED_X 0 # stop the pitch entirely
DODECA:SPEED_X OK
Setting all three to 0 freezes the wireframe — a still test image
rather than a moving one. The image PV itself is read-only; a PUT to it is
rejected.
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.
spgateway
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
A p4p-compatible PVAccess gateway. It reads a p4p-schema JSON configuration
(with an additive x-spvirit superset for spvirit-only features — metrics,
audit, hot reload, negative-search caching, rate limiting) and proxies
PVAccess traffic between an upstream "client" network and a downstream
"server" network.
spgateway <config.json>
spgateway -T <config.json>
spgateway --test-config <config.json>
spgateway -v <config.json>
Requires the client and server features.
| Flag | Meaning |
|---|---|
-T, --test-config | Parse and validate the configuration, print OK or the error, and exit (0 on success, 1 on error). Does not start the gateway. |
-v, --verbose | Raise the log level from the default INFO to DEBUG. Ignored under -T. |
--discovery-parity / --no-discovery-parity | Force the per-server discovery_parity field on / off for every server, overriding whatever the JSON says. Passing both is an error. The override also applies under -T, so -T validates the effective config. |
Flags may appear in any position relative to the config path; the first non-flag argument is taken as the config file.
Validating a configuration
$ spgateway -T gateway.json
OK
$ spgateway -T broken.json
invalid config: server 's' references unknown client 'nope'
Running
$ spgateway gateway.json
spgateway <config.json> starts the gateway: it builds one shared upstream
client pool and one PVA server per servers[] entry, then serves until it
receives Ctrl-C. Each server resolves names across its configured clients
(in order), reads with getholdoff staleness suppression, writes, and
fans out monitors — proxying between the upstream "client" network and the
downstream "server" network. Requires the client and server features.
A normal start is not silent: the gateway installs a tracing subscriber at
INFO and logs a one-line-per-server startup banner (which port each server
listens on and which upstreams it proxies), plus one Status PV: … line per
status PV when a statusprefix is set, plus any warnings/errors. -v adds
per-module DEBUG detail.
Discovery parity (discovery_parity)
By default each servers[] entry binds its downstream UDP search-receive
socket broadly so that broadcast and multicast PVAccess searches are heard,
matching p4p/pvagw. This is controlled by a per-server boolean field,
discovery_parity, placed at the top level of a server block (a sibling of
interface, serverport, bcastport), defaulting to true:
{
"version": 2,
"servers": [
{ "name": "downstream", "clients": ["upstream"],
"interface": ["10.0.0.5"],
"discovery_parity": true }
]
}
| Value | UDP search socket | Multicast |
|---|---|---|
true (default) | Binds 0.0.0.0:<bcastport> (all interfaces) | Joins the PVA multicast group 224.0.0.128 |
false | Binds only the configured interface IP | Does not join multicast |
A socket bound to a specific unicast IP does not receive broadcast or
multicast datagrams at the OS level (notably on Windows), so with parity
off and an interface pinned, only unicast searches (a client pointed
directly at the host) ever reach the server. Parity on binds 0.0.0.0
and joins 224.0.0.128, so a plain broadcast search from the local machine
discovers the gateway's PVs the same way it discovers a p4p gateway. A p4p
config that has no such key therefore gets p4p-equivalent discovery, because
the field defaults to true. The multicast join is best-effort: if it fails
the server logs a warning and continues serving unicast/broadcast searches.
Only the UDP search socket is affected. The TCP listener still binds the
configured interface IP, and the address advertised in search responses
remains the interface IP — parity changes which searches are heard, not
where connections are made or what address is handed back.
The --discovery-parity / --no-discovery-parity CLI flags override this
field for every server, taking precedence over the JSON value. This is
convenient for toggling the behavior across a whole config without editing it
(for example, --no-discovery-parity to restore interface-pinned binding on
all servers).
Status
M1 is a passthrough gateway with access control enforced: it resolves,
reads (get), writes (put), and monitors (subscribe) PVs across networks,
with per-server negative-search caching, getholdoff, readOnly/pvlist/ACF
enforcement (see below), and loop/self-connection prevention.
Not yet enforced in M1 (parsed but inert, or deferred to a later milestone):
the x-spvirit metrics / audit / hot-reload / rate-limit blocks, RPC
forwarding to upstream servers (the local asTest status RPC below is
answered by the gateway itself, not proxied), and a true upstream pvlist
fan-out for downstream splist (names() reports only PVs this gateway has
already claimed), and the per-server acf-client field (parsed and
referentially validated against clients[], but not yet consulted at
runtime). Ctrl-C shutdown is immediate rather than a graceful drain.
These and the representation limits (array-of-structure, union/any,
non-finite floats, multi-token addrlist) are collected on the
Known gaps page.
Access control
Each servers[] entry can restrict what it proxies with three independent
inputs, evaluated in a fixed precedence:
| Precedence | Input | Config field | Effect |
|---|---|---|---|
| 1 (highest) | Read-only mode | top-level readOnly | Denies every put and RPC. Never affects get/subscribe. |
| 2 | pvlist | per-server pvlist (path to a p4p-format ALLOW/DENY/ALIAS file) | First matching rule wins. DENY hides the PV from every operation, including reads. ALLOW/ALIAS bind an ASG/ASL for step 3. An ALIAS rule also rewrites the name the gateway proxies upstream. |
| 3 (lowest) | access | per-server access (path to a .acf file: UAG/HAG/ASG blocks) | Grants or denies put/RPC by matching the caller's user against UAG and host against HAG, gated by the ASG/ASL bound in step 2 (or DEFAULT/0 if no pvlist is configured). Never consulted for get/subscribe — only a pvlist DENY can hide a read. |
If no pvlist is configured for a server, every PV is eligible (step 2 is
skipped entirely) and falls through to the ACF step under the implicit
DEFAULT ASG at ASL 0. If a pvlist is configured, a PV that matches no
rule in it is denied — first-match-wins with no fallthrough default is a
fail-closed design, matching p4p/pvagw.
.acf support is a documented subset: UAG, HAG, and ASG/RULE blocks
parse; CALC guard expressions in a RULE are a hard parse error rather than
being silently ignored, so a config that depends on CALC fails -T
validation instead of serving with a weaker rule than the operator intended.
A duplicate UAG, HAG, or ASG name is also a hard parse error, so a
second definition can never silently shadow the first. READ/GET,
WRITE/PUT, and RPC are the recognised RULE operations; any other
op keyword is rejected. Referenced but undefined UAG/HAG names, and an
ASG absent from the file, fail closed (they grant nothing) rather than
erroring.
Fail-closed configuration loading
validate() (and therefore spgateway -T) loads and parses every
configured pvlist and access file up front. A missing file, a file that
cannot be read, or one that fails to parse is a validation error — the
gateway refuses to start rather than falling back to "no restriction". There
is no way to configure a pvlist/access path that is silently ignored if
broken.
clients[].provider is also validated: spvirit only speaks PVAccess
upstream, so any client whose provider is not "pva" (the default) fails
validation rather than being silently accepted and then never resolving
anything.
$ spgateway -T gateway.json
invalid config: pvlist '/etc/pvagw/pvacl.conf': No such file or directory (os error 2)
Loop / self-connection guard
A bidirectional gateway (or two gateway instances on the same host) must
never resolve a PV search back into one of its own downstream servers. Each
server's LoopGuard bans:
- Own-server sockets — every
servers[].interfaceIP (or, for a server with nointerface, every local interface address the process can enumerate — the0.0.0.0backstop) paired with that server'sserverport. This is socket-specific: a real upstream IOC sharing the gateway's IP but a different port still resolves normally. ignoreaddrhosts — an operator-supplied list of hostnames/IPs, forward-resolved to IPs and banned on every port.- The gateway's own server GUIDs — generated up front at startup for
every
servers[]entry, before any client connects. A search response carrying one of these GUIDs is treated as a self-reference and rejected regardless of which address it claims to come from. This closes the gap a socket-only ban leaves against the default0.0.0.0bind, where the gateway's own listening address may not be enumerable (or may be reachable under an address the socket ban doesn't cover) before the guard is built.
Status PVs
Setting a server's statusprefix (e.g. "gw:status:") serves 15
introspection PVs under that prefix, gated through the same AccessControl
as the data plane:
The shapes match the p4p gateway's status PVs (not plain scalars): clients
and cache are NTScalarArray string lists, refs and the bandwidth PVs are
NTTables, and asTest returns p4p's epics:p2p/Permission:1.0 structure.
Live PVs post a fresh monitor frame whenever their underlying value changes
(the monitor pump suppresses byte-identical frames, so a constant value posts
once and then stays quiet — matching p4p's post-on-change).
| Group | PVs | Shape / Notes |
|---|---|---|
| Live | clients, cache | NTScalarArray (string list). cache is the live list of upstream channels held in the monitor cache — it updates as monitors come and go. clients (the downstream-peer list) has no M1 registry to read yet, so it is served as an empty list (correct shape; data-population follow-up). |
| Live | refs | NTTable with columns type/count/delta (labels Type/Count/Delta), matching p4p's RefAdapter. No object-refcount collector exists in M1, so the rows are empty (correct shape; data follow-up). |
| Live | stats | epics:p2p/Stats:1.0 structure of cache-size counters, matching p4p's statsType (six NTScalar('L') ulong fields: ccacheSize, mcacheSize, gcacheSize, banHostSize, banPVSize, banHostPVSize). mcacheSize is live (the active upstream-monitor count); the other five are 0 — spvirit has no channel/GC caches or ban lists in M1 (correct shape; data follow-up). |
| Live (writable) | poke | NTScalar. The one writable status PV: a put bumps an internal generation counter, and poke's own value reports it — useful for confirming the status source is alive. |
| Static | ds:bypv:rx/tx, ds:byhost:rx/tx, us:bypv:rx/tx, us:byhost:rx/tx | NTTables matching p4p's bandwidth tables (bypv -> PV/rate; us:byhost -> Server/rate; ds:byhost -> Account/Client/rate; the rate column is labelled TX (B/s)/RX (B/s)). All have empty rows in M1 — no per-PV/per-host byte accounting exists yet, so 0 rows != "no traffic". |
| Static + RPC | threads | Gettable/subscribable static NTScalar('s') whose value is the constant string "RPC only", matching p4p's SharedPV(nt=NTScalar('s'), initial='RPC only'). It also responds to RPC, which returns a shape-correct best-effort thread summary (available-parallelism plus a note that an OS-level stack dump is not collected and that work runs on tokio async tasks). As in p4p, the get/subscribe value never changes — the richer detail is available only via RPC. |
| RPC | asTest | Diagnostic: evaluates put/rpc access for a {pv, user, host} argument struct against this server's AccessControl, without touching any upstream. Returns p4p's epics:p2p/Permission:1.0 structure (pv, account, peer, plus a nested permission sub-structure of put/rpc/uncached/audit booleans). roles/asg/asl are present for shape parity but empty/zero — spvirit's AccessControl does not model them yet. |
Trust boundary
The connecting client supplies its own user and host identity in the
PVAccess ca connection-validation credentials, and the gateway decodes
them as-is — nothing about that exchange authenticates the claim.
For host, the gateway does not trust the client's word: pvlist FROM/ACF HAG matching is always evaluated against the TCP socket's actual
peer-IP address, never against the client-asserted host string in the ca
credentials. That asserted host is decoded and available for diagnostics
(e.g. asTest), but it is advisory only and never feeds an access decision
— trusting it would let a client claim a trusted hostname it isn't actually
connecting from and bypass host-based rules.
For user, the gateway matches p4p/pvagw's long-standing posture: the
ca-asserted user is trusted as-is for UAG matching, with no independent
authentication. UAG is therefore authorization, not authentication —
operators who need real user authentication should treat this like any
other unauthenticated network boundary, since M1 has no mechanism (TLS
client certs, Kerberos, etc.) to bind the declared identity to the
transport.
Crate map
✅ Verified · no code on this page ·
The badge reports the whole
docs-verifysuite, not this chapter alone.
Spvirit is nine crates in one workspace. They are published separately, so
you depend on the layer you need and nothing above it. Eight of them form the
layered stack below; the last, spvirit-calc, stands apart.
The layering
graph TD
types[spvirit-types]
codec[spvirit-codec]
client[spvirit-client]
server[spvirit-server]
ioc[spvirit-ioc]
gateway[spvirit-gateway]
tools[spvirit-tools]
py[spvirit-py]
codec --> types
client --> codec
client --> types
server --> codec
server --> types
ioc --> server
ioc --> codec
ioc --> types
gateway --> client
gateway --> server
gateway --> codec
gateway --> types
tools --> client
tools --> server
tools --> gateway
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-ioc | codec + server | You want the higher-level IOC layer built on the server (scan/process infrastructure). | docs.rs |
spvirit-gateway | client + server | You are building a PVAccess gateway — the proxy engine behind spgateway. | docs.rs |
spvirit-tools | client + server + gateway | 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 |
The remaining 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.
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; also required (with client) by spgateway |
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, spvirit-server,
spvirit-ioc, spvirit-gateway and spvirit-calc 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" (message at db.rs:558) 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:629) 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 layers
| Layer | Import | What it gives you |
|---|---|---|
| Typed PV handles (tier 2) | spvirit.ai, spvirit.ao, … | IOC-style records you keep a handle to. The one to start with. |
| IOC engine (tier 3) | spvirit.ioc.ai, …, spvirit.Ioc | A processing IOC built from record specs or .db text. Verbatim EPICS field names (EGU, PREC, …), record graph, .db round-trip. |
| Server + store | spvirit.Server | Runs the PVAccess server; store reaches records by name. Accepts pvs=, ioc=, sources=, and .db via db_file=/db_string=. |
| 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. |
Monitor callbacks (spvirit.monitor, Subscription, Channel.monitor)
receive a MonitorUpdate — .value plus .changed/.overrun (dotted field
paths) and .has_overrun — rather than the bare value.
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.
The tier-3 IOC layer has its own record constructors under spvirit.ioc
(ai ao bi bo longin longout). These take verbatim EPICS field names as
keyword arguments — spvirit.ioc.ao("SETP", EGU="V", PREC=3), not tier 2's
units=/prec= — because a tier-3 record round-trips to .db text. Pass the
resulting specs to spvirit.Ioc(records=[...]) (or build from db_file= /
db_string=).
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 forty 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:631) 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:57). 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:237).
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 (should_post_update,
spvirit-server/src/simple_store.rs:571).
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:659) 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:1795). 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. Large values are sent as one frame, never segmented
spvirit reassembles segmented messages it receives — SegmentReassembler in
spvirit-codec/src/segment.rs:34, driven by the client transport and the
server handler — but it never emits them. encode_header
(spvirit-codec/src/spvirit_encode.rs:66) never sets the segmentation bits,
and no encode path splits a payload, so a 4 MB NTNDArray goes out as a single
PVA frame with a four-byte payload length where EPICS Base would split it.
Clients that accept large unsegmented frames — pvxs, p4p, PVAJava — read it
fine; a client that enforces a maximum frame size will not. Tracked as
roadmap item 6 in
Current State and Roadmap.
Gateway (M1): passthrough only
spgateway in M1 is a passthrough gateway — it resolves, reads, writes, and
monitors PVs across networks, with readOnly/pvlist/ACF access control
enforced, but the observability layers (metrics, audit, hot reload) are not
yet wired. The divergences below are deliberate for this milestone and are
documented in full on the spgateway page.
Representation. The proxy round-trips through spvirit's decoded value model,
which does not carry every PVAccess shape losslessly. An array-of-structure
value (e.g. an NTNDArray dimension) is proxied as an index-keyed structure
rather than a true array; union/any fields degrade; non-finite floats
(NaN/Inf) become JSON null on the put path; and deeply nested structure
IDs are best-effort.
Resolution. Only the first token of a client's addrlist is used. A p4p
config with autoaddrlist:false plus an explicit unicast addrlist now does
perform the unicast search (an earlier defect where it searched nothing at all
is fixed), but it still emits a subnet broadcast alongside the unicast probe —
the underlying spvirit-client search branch cannot be changed from the
gateway. Malformed addrlist/interface entries degrade silently rather than
erroring.
Control plane. names() (what a downstream pvlist sees) reports only the
names this gateway has already claimed, not a true fan-out of each upstream's
pvlist — the gateway resolves names dynamically and caches no upstream
SocketAddr in M1. rpc forwarding is not implemented: there is no general
client RPC entry point yet, so an RPC to the gateway returns
"gateway RPC forwarding is not implemented in M1".
Access control and ops. readOnly, pvlist, and .acf access control
are enforced (see the spgateway page) — this
is no longer a passthrough-only gap. What remains unwired: the x-spvirit
metrics, audit, hot-reload, and rate-limit blocks are parsed and validated
but not consumed, and RPC forwarding to upstream servers is not implemented.
The per-client acf-client field is likewise parsed and referentially
validated against the clients[] list but never consulted at runtime.
Ctrl-C shutdown is immediate: outstanding requests are hard-cancelled rather
than drained gracefully.
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 |
| 09 | The record-processing engine | Before touching spvirit-ioc: lock sets, load-time diagnostics, and what sub-projects B, C and D still owe |
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-ioc (scan/process; server-only)
└──────┬───────┘
┌──────┴───────┐
spvirit-gateway spvirit-py (both consume client + server)
(proxy engine) (PyO3 bindings)
│
spvirit-tools
(CLI binaries; client + server + gateway)
The authoritative dependency graph, including spvirit-calc (which stands
apart), is the mermaid diagram on the Crate map.
- 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-ioc — the higher-level IOC layer built on the server: scan and process infrastructure. Depends on the server (and codec/types), not the client.
- spvirit-gateway — the PVAccess proxy engine behind the
spgatewaybinary: it consumes both the client (to reach upstream servers) and the server (to face downstream clients). Chapter 04 (spgateway). - spvirit-tools — 13 CLI binaries (
spget,spput,spmonitor,spexplore,spserver,spgateway, …) plus the workspace's integration/interop test suite. Depends onspvirit-gatewayfor the gateway binary. 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 (ConnWriter)
▼ ▼ │
SourceRegistry ──► SimplePvStore ──► MonitorRegistry
(priority list) (records, MDEL, (self-contained
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). A third, for sources whose values
change upstream (gateway, group), is a per-PV pump task that drains
Source::subscribe — started at monitor init for any source that does not
self-notify (Source::pushes_own_updates). All three end at
MonitorRegistry::notify_monitors, which builds a self-contained,
fully-filtered frame per subscriber (the stored delta baseline serves only as
a change detector, never as wire output) and hands the bytes to each
connection's ConnWriter — a two-lane flat-combining writer that coalesces
monitor frames on a latest-per-ioid lane while control replies take a
lossless FIFO lane.
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). The same lifetime
rule applies to
SegmentReassembler: one per connection, because it holds the message currently being reassembled. - 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 spec-exact by default — changed bitset,
data, overrun bitset — and every workspace call site uses it. The old
try-every-layout scoring heuristic still exists behind
DecodeMode::Lenientfor mid-stream captures of implementations that disagree; it has no in-workspace caller, so do not delete it as dead code. - 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:51)
▼
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 (~654 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–99 |
NtAlarm / NtTimeStamp / NtDisplay / NtControl | lib.rs:101–129 | Normative sub-structures |
NtScalar | lib.rs:131 | The big one: value + flattened alarm/display/control/valueAlarm/units + optional time_stamp (lib.rs:167) |
NtScalarArray | lib.rs:337 | Array payload |
NtTable / NtTableColumn | lib.rs:358–391 | Table; validate() checks column-length equality |
NtNdArray + NdCodec/NdDimension/NtAttribute | lib.rs:393–497 | Image/detector model; validate() checks dims × element size vs uncompressed_size |
NtEnum | lib.rs:516 | index: i32 + choices: Vec<String>; selected() |
PvValue | lib.rs:552 | Recursive value tree (Scalar/ScalarArray/Structure) so this crate can represent arbitrary structures without depending on the codec |
NtPayload | lib.rs:562 | 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:271) 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:161–167. 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 | 51 | Module decls + curated re-exports (also re-exports spvirit_types) |
encode_common.rs | 28 | encode_size (PVA varint) + encode_string |
error.rs | 250 | DecodeError (the eleven typed decode failures) + DecodeResult, plus Utf8Policy and the shared decode_size_prefixed / decode_string_prefixed core (single source of truth for size-prefix + string decode) |
segment.rs | 315 | SegmentReassembler / SegmentOutcome: sans-io reassembly of segmented PVA messages |
monitor.rs | 621 | Monitor deltas: MonitorUpdate, MonitorLayout, the three bitset-layout body decoders, the lenient scoring heuristic |
epics_decode.rs | 2142 | PVA wire-format decode: header, control flags, ~20 command payload structs, PvaPacket::decode_payload dispatch, PvaOpPayload, DecodeMode |
spvd_decode.rs | 1843 | pvData (PVD) introspection + value decode: TypeCode, FieldDesc, StructureDesc, DecodedValue, DecodeLimits, PvdDecoder (incl. introspection registry, bitset decode) |
spvd_encode.rs | 3472 | pvData encode / NT serialization: struct-desc encoding, per-NT-type encoders, bitset/delta/monitor encoding, PvRequest encode/decode, projection/filtering |
spvirit_encode.rs | 1436 | PVA wire-format encode: encode_header, all request/response builders (search, create-channel, op init/data/status, monitor, beacon) |
spvirit_state.rs | 1948 | 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:216), 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. Decode is now consolidated: error::decode_size_prefixed
(error.rs:109) and error::decode_string_prefixed (error.rs:146) are the
single source of truth. Both epics_decode::decode_size/decode_string
(epics_decode.rs:329/340) and PvdDecoder::decode_size/decode_string
(spvd_decode.rs:389/398) are thin wrappers over them — the split that remains
is the deliberate Utf8Policy one: framing decodes Lossy (passive observer),
structured pvData values decode Strict (a non-UTF-8 value is a real error).
Encode still has three near-identical size helpers
(encode_common::encode_size at encode_common.rs:5,
spvd_encode::encode_size_pvd at spvd_encode.rs:15,
spvirit_encode::encode_size_pva at spvirit_encode.rs:12) — 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:403) → 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:23) plus per-NT descriptor
builders (nt_scalar_desc:275, nt_scalar_array_desc:709, nt_table_desc:765,
nt_ndarray_desc:934, nt_enum_desc:1192, dispatcher nt_payload_desc:1305).
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:364) —
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,
spvd_decode.rs:1205, flattens the count). Decoding is spec-exact by
default — changed bitset, data, overrun bitset — via
PvdDecoder::decode_monitor_update (monitor.rs:116), which returns a
MonitorUpdate (monitor.rs:19): the decoded value, both raw bitsets, the
bytes consumed, and the bit-indexed field paths captured at decode time
(changed_paths:65, overrun_paths:71, overrun_fields:58 — the last takes
the descriptor explicitly). PvaOpPayload::decode_with_field_desc
(epics_decode.rs:1512) selects the policy with a DecodeMode
(epics_decode.rs:1551); every call site in the workspace passes
DecodeMode::Strict. DecodeMode::Lenient retains the old
try-every-layout scoring heuristic — decode_monitor_update_lenient
(monitor.rs:140) reports the winning MonitorLayout (monitor.rs:38) — for
mid-stream captures and peers that disagree on the ordering. It is public API
for out-of-tree consumers; nothing in the workspace selects it. The
single-bitset decoders decode_structure_with_bitset (spvd_decode.rs:1006)
and pub(crate) decode_structure_with_bitset_body (:1036) are what
monitor.rs builds on. Encode side: encode_nt_payload_delta
(spvd_encode.rs:2339), compute_changed_bits:2235,
encode_structure_bitset:465.
The path order in flatten_field_paths (monitor.rs:99) must stay in step with
count_structure_fields, which numbers the bits: depth-first, self then
nested. If the two diverge, overrun bits map to the wrong field names.
Op payloads. PvaOpPayload::new (epics_decode.rs:1414) 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:821) is recursive
over FieldType. Every array count is checked before anything is allocated
(check_array_count, spvd_decode.rs:798): a count above its DecodeLimits
ceiling is DecodeError::ArrayTooLarge, and a count that cannot fit in the
bytes that remain is DecodeError::CountExceedsBuffer. The limit is checked
first, so a corrupt length that violates both is reported as ArrayTooLarge.
Nothing is silently truncated. DecodeLimits (spvd_decode.rs:23) defaults
to 4 000 000 elements for scalar arrays and 65 536 each for string, structure,
union and variant arrays; override per decoder with PvdDecoder::with_limits
(spvd_decode.rs:373) and read them back with limits().
Segmentation is reassembled by the codec. The flags byte carries
first/middle/last segment bits, decoded into PvaControlFlags
(epics_decode.rs:75). SegmentReassembler (segment.rs:34) turns a run of them
back into one message: push (segment.rs:65) takes an 8-byte header plus its
payload and answers SegmentOutcome::Pending, ::Complete — the first
segment's header with the segment bits cleared and payload_length rewritten
to the concatenated total — or ::Control, since control frames are legal
between the segments of one message and leave the in-progress state alone. It
is sans-io and holds one message at a time, so it needs one instance per
connection — the same lifetime rule as PvdDecoder. The ceiling is
DEFAULT_MAX_MESSAGE_BYTES (segment.rs:7, 256 MiB), overridable with
with_max_bytes (segment.rs:46). spvirit-client's read_packet
(transport.rs:63) and the server handler (handler.rs:834) both drive one.
Emission is still not implemented. Nothing in the workspace splits an oversized outgoing message into segments; the server sends one large frame regardless of size. Roadmap item 6 in Current State and Roadmap.
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:870) 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::new(epics_decode.rs:138) andPvaPacket::new(epics_decode.rs:201) panic on input shorter than the 8-byte header — the former via.expect(...). Use the falliblePvaHeader::try_new(epics_decode.rs:142) /PvaPacket::try_new(epics_decode.rs:211), which returnNone, on any network-ingress path where the length is attacker-controlled (e.g. raw UDP datagrams).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 21,
spvirit_encode 20, spvirit_state 23, spvd_decode 18, epics_decode 13,
monitor 10, segment 10, error 6, types 5.
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 |
|---|---|---|
pva_server.rs | 2335 | PvaServer + PvaServerBuilder (classic API), ServeBuilder/RunningServer (handle API), shared record-construction helpers make_scalar_record/make_output_record/make_array_record |
handler.rs | 2066 | The core. TCP connection processor (handle_connection), UDP search responder (run_udp_search), TCP accept loop, ServerState, wildcard matching, GUID generation, MAX_PIPELINE_WINDOW |
simple_store.rs | 1586 | SimplePvStore: in-memory Source backed by RecordInstances — value/NT writes, subscribers, MDEL gate, link evaluation, PUT application, NTScalar descriptor builders |
pv.rs | 1498 | Typed handle layer: Pv<T>, PvArray, AnyPv, PvScalar trait, pending/bound state machine, builder methods, attach |
group.rs | 1161 | QSRV-style group PVs: info(Q:group) JSON parsing → GroupPvDef, and GroupSource composing members into NtPayload::Generic |
types.rs | 1104 | Record model: RecordType, ScanMode, LinkExpr, DbCommonState, RecordData, RecordInstance + value-mutation methods |
apply.rs | 1104 | Pure functions applying a decoded PUT to NT payloads (apply_value_update, apply_table_put, apply_ndarray_put, …) |
db.rs | 1090 | EPICS .db file parser (regex, line-oriented) |
events.rs | 736 | Events: server-wide on_start hooks, named on_event handlers, the EventSink trait, and the single-task dispatcher behind post_event |
monitor.rs | 724 | MonitorRegistry: per-PV subscriber lists, the per-connection ConnWriter map, build_monitor_frame (pure, self-contained frames), pipeline credit accounting, subscribe-only pump management |
record_fields.rs | 687 | QSRV-style field access: serves <pv>.<FIELD> and <pv>.<FIELD>$ as read-only channels; dbCommon defaults table |
pvstore.rs | 572 | The Source trait + PvInfo + SourceRegistry |
conn_writer.rs | 484 | ConnWriter: the per-connection two-lane flat-combining writer (coalescing monitor lane + FIFO lossless control lane) that replaced the old mpsc-plus-writer-task tail |
field_provider.rs | 308 | RecordFieldProvider: the value-level field-access seam shared by SimplePvStore and spvirit-ioc (field_descriptor resolves type without a read) |
convert.rs | 276 | DecodedValue → ScalarValue/ScalarArrayValue conversions |
server.rs | 186 | Orchestration: run_pva_server_with_registry binds TCP/UDP/beacon and joins the tasks |
request_ctx.rs | 116 | Task-local per-connection request context (peer address now; ca user/host reserved for M2), visible to Source impls |
decode.rs | 110 | 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 (
handle_connection, handler.rs:838): a per-connection reader whose write half is wrapped in aConnWriter(handler.rs:848) — the flat-combining two-lane writer that replaced the old dedicated writer task draining anmpsc::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; CANCEL_REQUEST / ACL_CHANGE / MESSAGE / MULTIPLE_DATA / ORIGIN_TAG and Op 14/16 answered with an error). 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:410) bumps the beacon counter and callsregistry.notify_monitorsper change. MonitorRegistry::notify_monitors(monitor.rs:221) builds one frame per subscriber via the purebuild_monitor_frame(monitor.rs:149) — first frame full (possibly field-filtered); a filtered subscriber's later frames are self-contained full filtered frames (not sparse deltas, so a coalesced drop cannot corrupt the client's value), no-op-suppressed when the filtered view is unchanged; an unfiltered subscriber gets full-or- suppressed (suppressed when unchanged). Pipeline credit (nfree, capped atMAX_PIPELINE_WINDOW) gates delivery. Each frame is then handed to the subscriber'sConnWriteron the correct lane (route_monitor_frame, monitor.rs:286).- The
ConnWriterdelivers it: a non-pipelined monitor goes on the coalescing monitor lane (latest-per-ioid, lossy under load); a pipelined monitor goes on the FIFO lossless control lane so no charged credit is ever coalesced away. Whichever task wins the flusher election drains the lanes (control first) to the socket — in the common case inline on the producing task, with no cross-thread wakeup.
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.
Subscribe-only sources (gateway proxies, group PVs, async backends) have a
third origin. Their values change upstream, never through a local PUT or store
write, so neither path above fires. At monitor init the handler checks
state.sources.pushes_own_updates(name) (handler.rs); if the source does not
self-notify, it calls subscribe(name) and hands the channel to
MonitorRegistry::ensure_pump (monitor.rs), which spawns one task per PV that
drains the channel into notify_monitors. The pump is shared across every
subscriber of that PV and retired (retire_pump_if_idle) when the last one
goes away; it holds a Weak reference to the registry to avoid a cycle.
Self-notifying sources (SimplePvStore, IocSource, PySourceAdapter) return
true and are skipped, so their updates are never delivered twice. Without this
pump a monitor through the gateway received only its initial snapshot and then
nothing.
Concurrency summary
Tokio tasks: UDP-search loop, TCP-accept loop, beacon loop, one per
connection, one per .scan(), detached on_put tasks, one per
group-subscription fan-in, one monitor pump per subscribe-only PV under
monitor. Note there is no dedicated per-connection writer task any more:
the ConnWriter flat-combines, so socket writes happen inline on whichever
task deposited the frame and won the flusher election. Locks: store pvs
(RwLock), monitor registry (Mutex), source registry (RwLock); each
ConnWriter guards its coalescing state with a std Mutex (never held
across .await) and its socket write half with an async Mutex; each Pv
handle's shared state is a std Mutex held only briefly, never across
.await. Channels: per-subscriber NtPayload (64) — the store's
Source::subscribe stream a monitor pump drains. The old per-connection
outbound mpsc(128) is gone, subsumed by the ConnWriter.
Monitor delivery: the ConnWriter
Every connection's write half is owned by a ConnWriter (conn_writer.rs),
stored in MonitorRegistry::conns keyed by connection id. It replaced the old
per-connection mpsc(128) plus a dedicated writer task with flat combining
(Hendler, Incze, Shavit & Tzafrir, SPAA 2010): producers deposit complete PVA
frames and one of them is elected "flusher" to drain them, so the common case
writes inline on the producing task with no cross-thread wakeup.
Two lanes give the combiner priority scheduling, decided at drain time when it holds the whole pending batch:
control— aVecDeque<Vec<u8>>: FIFO, never coalesced, never dropped. Carries the handshake, GET/PUT/PUT_GET/RPC and monitor-init responses, errors, and control frames. Drained first, so a monitor's INIT response always precedes that ioid's DATA frames. Also carries every pipelined monitor DATA frame (see below).monitor— aHashMap<u32, Vec<u8>>keyed by ioid: latest-frame-per-ioid conflation. A newer frame for an ioid replaces the older one, so intermediate monitor values are dropped under load — exactly like pvxs/pvagw. Carries non-pipelined monitor DATA frames only.
send_control and send_monitor deposit under a std Mutex and, if no flusher
is active, set the flushing flag and become the flusher. The drain, the
empty-check and clearing flushing all happen under a single lock acquisition,
so there is no lost wakeup. Cross-operation reordering is protocol-legal: PVA
frames are correlated by request id, not stream position. A dead flag, set on
the first socket-write error, makes all further deposits no-ops so a
dead-but-not-closed peer cannot wedge producers. A FlushGuard gives cancel
safety: if the flusher future is dropped mid-write_all (its task cancelled),
the guard hands the election to a spawned continuation so pending bytes are
never stranded and no depositor is wedged behind a stale flushing == true.
Frame building is pure. MonitorRegistry::build_monitor_frame (monitor.rs:149)
takes only a MonitorSub and an NtPayload and returns Option<Vec<u8>>:
Some(bytes) when there is something to send, None for a no-op (a duplicate
under the subscriber's field view). A None does not consume pipeline
credit and does not advance the delta baseline. Because the monitor lane may
drop intermediate frames, every subsequent filtered frame is a self-contained
full filtered frame, not a sparse delta — a dropped self-contained frame is
harmless, whereas a dropped delta would silently corrupt the client's value.
Pipelined flow control is lossless. A MonitorSub (state.rs) carries per-sub
nfree, last_snapshot, filtered_desc and pipeline_enabled. Pipelined
subscribers do credit-based flow control, so every charged frame must arrive;
route_monitor_frame (monitor.rs:286) sends their frames on the lossless
control lane, never the coalescing monitor lane — coalescing one away would
spend a credit the client never receives and drift its window until it stalls.
The client-supplied window is capped server-side at MAX_PIPELINE_WINDOW = 4096
(handler.rs:48) at three points: monitor init, ACK refill, and the registry's
update_monitor_subscription gate — so a stalled pipelined client cannot grow
the lossless lane without bound.
Pump retirement is cooperative (a oneshot shutdown signal, not abort):
aborting a pump could drop its future mid-write_all inside a shared
ConnWriter and wedge that connection's flusher, so retire_pump_if_idle
lets any in-flight notify_monitors finish first.
The standalone
spserverbinary is a separate implementation and does not useConnWriter; it keeps the older per-connectionmpsc(128)plus a writer task, borrowing only the purebuild_monitor_frame. It has noMAX_PIPELINE_WINDOWcap.
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 |
on_start | once, awaited in registration order, before scans/dispatcher/listener | yes — a panic aborts run()/run_start_hooks(), naming the hook | PvaServerBuilder::on_start / ServeBuilder::on_start |
on_event | queued by post_event, run one at a time on the dispatcher task | no — catch_unwind logs and counts, dispatcher continues | PvaServerBuilder::on_event / ServeBuilder::on_event |
EventSink::on_event | awaited inline by post_event, before any handler is queued | no — catch_unwind logs and counts, the fan-out continues | PvaServerBuilder::event_sink / ServeBuilder::event_sink / Events::add_sink |
Lifecycle hooks and named events (events.rs)
Events (events.rs:71) is one Arc shared by the builder and the built
PvaServer: sinks: RwLock<Vec<Arc<dyn EventSink>>>, handlers: RwLock<HashMap<String, Vec<EventHandler>>>, an mpsc::channel of capacity
DISPATCH_QUEUE_CAPACITY (1024), and AtomicU64 counters for drops
(dropped_count) and handler panics (failed_count).
EventSink(events.rs:40) is an async trait —fn on_event(&self, event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>— awaited inline byEvents::post, in registration order, before any handler is queued. It is a boxed future rather than a plainfnfor the same reasonEventHandleris: every store mutation isasync, andpostis reachable from inside the dispatcher on acurrent_threadruntime, whereHandle::block_onpanics,block_in_placepanics, andfutures::executor::block_ondeadlocks on the store's tokioRwLock— so a sync signature could not be honoured by any sink that touches a record. The returned future borrows&self, notevent. This is the seam a future EPICS-EVNT-scan-list consumer implements; nothing in this repo registers a sink today.EventHandler(events.rs:50) is the deferred counterpart:Arc<dyn Fn(Arc<SimplePvStore>, String) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>.Events::post(events.rs:170) awaits the sinks — each wrapped incatch_unwind(AssertUnwindSafe(..)), so a panicking sink is logged, counted infailed_count, and neither truncates the fan-out nor stops handlers being queued — then incrementsinflightfor the whole batch before enqueueing any of it (so a concurrentdrain()can never observeinflight == 0mid-batch), andtry_sends each handler — a full queue drops and counts rather than blocking the poster.start_dispatcher(events.rs:133) spawns the single task that drains the channel and awaits each handler wrapped infutures::FutureExt::catch_unwind(AssertUnwindSafe(fut)): a panicking handler is logged, counted infailed_count, and the loop continues — handlers never abort the dispatcher. Handlers therefore run strictly one at a time, in the order they were enqueued across all events, so a slow handler for one event delays every other event's handlers behind it.drain()(events.rs:232) is test-only: it pollsinflightdown to zero with a 10 s timeout that panics by name rather than hanging CI if the dispatcher-invariant (catch_unwindalways present,inflightalways decremented) is ever broken by a regression. It first checks thedispatcher_startedflag and fails immediately if handler invocations are queued with no dispatcher to run them —build() -> post_event() -> drain_events()without a start used to burn the full 10 s and then blame the dispatcher.
Startup hooks (StartHook, events.rs:57) are not part of Events —
they are a plain Vec on PvaServer (start_hooks, pva_server.rs:692),
run by run_start_hooks() (pva_server.rs:735) to completion, in order,
before serve_after_start_hooks() builds the source registry, spawns
scan tasks, starts the event dispatcher, and binds (pva_server.rs:823–905:
run() is exactly run_start_hooks().await? then
serve_after_start_hooks().await). A panicking hook returns
Err(format!("on_start hook #{i} panicked; aborting startup: {cause}")) —
the catch_unwind payload is downcast to String/&str, so the real cause
(and any label the hook panicked with, such as a Python source's) survives
instead of reaching the user only through the default panic hook on stderr
— and run() never reaches serve_after_start_hooks: no scan task,
dispatcher, or listener starts.
ServeBuilder::start() runs the same hook phase to completion before it
returns (Result<RunningServer, String>), so an aborting hook fails the
call rather than handing back a handle to a server that never bound, and
RunningServer::pv(...) can never read a pre-hook value. start() then
starts the dispatcher (idempotent) before spawning, so
RunningServer::post_event does not race the spawn; RunningServer keeps
its own Arc<Events> (events(), post_event()) because start() moves
the PvaServer into the spawned task.
The two APIs order the dispatcher differently. run() is hooks →
sources → scan tasks → dispatcher → bind. start() and Python's
start_background() are hooks → dispatcher → spawn(sources → scan tasks →
bind), because the dispatcher must be running before the call returns or a
post_event() on the next line would race the spawned task. The consequence
is that on the handle API an on_event handler can run before
.source()-registered sources exist and before any scan task is spawned.
Handlers receive only Arc<SimplePvStore>, and nothing has bound on either
path, so this is not observable by a client — but a handler must not assume
a source is registered. What both orders share is the part that matters: no
hook runs late, and nothing binds before every hook has returned.
run_start_hooks also installs the MonitorRegistry onto the store before
the first hook runs (pva_server.rs:736), so a hook that writes the store
reaches any monitor subscribed later, and a hook that reads the registry
never sees None.
Python sources fold into the same list. spvirit-py/src/server.rs
registers a source's on_start(notifier) as one more PvaServerBuilder::on_start
closure, at the point add_source/.add_source() is called — so a source's
hook and a @builder.on_start hook interleave in true registration order,
not "sources first" or "hooks first". This changed behaviour from an
earlier revision where a Python source's on_start fired eagerly inside
build(); see the Python-facing note in Custom data
sources.
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 (~5,300 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 | 1091 | High-level API: PvaClient, PvaClientBuilder, PvaChannel (streaming PUT), monitor loop, pvinfo |
search.rs | 1290 | UDP broadcast/multicast search + discovery, TCP name-server search, resolve_pv_server, EPICS env vars |
pvlist.rs | 771 | PV-name listing with 4 fallback strategies (__pvlist, GET_FIELD, server RPC, server GET) |
put_encode.rs | 633 | JSON→PVD PUT payload encoder (bitset partial encoding, scalars/arrays/unions/variants) |
client.rs | 358 | Low-level channel lifecycle: establish_channel, pvget/pvget_fields |
format.rs | 521 | Output rendering (text/JSON), NT metadata extraction, NTTable formatting |
types.rs | 97 | PvOptions, PvGetResult, PvMonitorEvent, PvGetError |
transport.rs | 532 | Framed-packet readers: read_frame (blocks through mid-frame stalls), read_frame_resumable (persistent FrameBuf, deadline-bounded, resumes after a dropped read), read_until, read_packet |
auth.rs | 25 | AuthNZ user/host resolution (options → env → "unknown") |
Discovery and search (search.rs)
resolve_pv_server (search.rs:872) 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:400): 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:323): deliberately skipped on Windows where the semantics are unsafe. Also do not "fix".
Operations
- Channel setup (
establish_channel, client.rs:121): 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(&MonitorUpdate) -> ControlFlow<()>;Breaksends best-effort DESTROY. Read timeouts are non-fatal (continue); 10 s echo keepalive. The loop reads withread_frame_resumableover a persistentFrameBuf: the echo-keepaliveselect!branch drops the in-flight read every ~10 s, and theFrameBufkeeps any mid-frame bytes so the next read resumes instead of desyncing the TCP framing. 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, and the ACK-credit accounting runs on every consumed update (decode success or failure) so a decode error can never leak a credit and stall the stream. Critical invariant (pva_client.rs:553–556): 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 | 338 | 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 | 289 | Multi-PV monitor (JoinSet); --raw, --json, --pipeline N |
spinfo | 193 | 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 | 3490 | Full PVA server binary: .db loading, hot-reload, beacons, MDEL, __pvlist/discovery modes; record logic comes from the spvirit-server crate |
spdodeca | 1165 | Self-contained single-PV server streaming a rotating dodecahedron as NTNDArray (does not use spvirit-server) |
sptable | ~1300 | 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 (tier 2), the
IOC engine (tier 3), 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 (the primary user doc). This chapter
covers the internals.
Packaging
Cargo.toml: packagespvirit-py,version.workspace = true— it now shares the single workspace version (0.3.4 at time of writing) with every Rust crate; it is no longer versioned independently.edition = "2024".[lib] name = "spvirit",crate-type = ["cdylib", "rlib"]— therlibis there only socargo test -p spvirit-py --no-default-features --features test-embedcan link a standalone test binary; the maturin build only ever produces/uses thecdylib. pyo3 0.24 (abi3-py39— one wheel for Python 3.9+),pyo3-async-runtimeshard-pinned =0.24.0, tokio 1.47.1, plusserde_jsonandtracing. Depends onspvirit-ioc(tier 3).- Features:
default = ["extension-module"](pyo3 links no libpython; the hosting interpreter supplies the C API).test-embed = ["pyo3/auto-initialize"]is test-only, for the#[cfg(test)]unit tests that need a real embedded interpreter inside thecargo testbinary (e.g. the asyncio-bridge race test insource.rsand the ordered-notify tests in that file). pyproject.toml: maturin backend, distribution namespvirit, versiondynamic(from Cargo.toml, so it tracks the workspace version). Pins the runtime dependencyspvirit-tools == 0.1.22.- There is no Python source package — the whole API is the compiled
module plus three Rust-registered submodules
spvirit.codec,spvirit.lowlevelandspvirit.ioc(each injected intosys.modulesat import sofrom spvirit.ioc import aoworks). - No
.pyitype stubs — a known gap for a binding whose selling point is a typed API. .venv/is gitignored (the build/test commands assume it exists locally); anydist/wheels/sdists on disk are gitignored build output, not committed.
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/, ~7,600 LOC)
| File | LOC | Purpose |
|---|---|---|
lib.rs | 117 | #[pymodule] — the authoritative index of the public surface; registers classes, 16 factory functions, submodules; initializes the runtime bridge; pins threading._main_thread at import |
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 | 139 | Exception hierarchy under SpviritError; TimeoutError/IoError dual-inherit builtins |
convert.rs | 573 | Value conversion layer, including the typed (strict) coercion layer (see below) |
pv.rs | 995 | PyPv + PvKind, all factories, on_put/scan/calc bridging — the central tier-2 file |
server.rs | 1,144 | PyServerBuilder, PyServer, PyStore, PyEventDecorator (lifecycle/event hooks) |
client.rs | 583 | PyClientBuilder, PyClient, PyGetResult, PyDiscoveredServer, PySubscription, discovery |
nt.rs | 740 | NT wrapper classes, NtPayload↔Python bridging |
source.rs | 1,225 | Python-defined dynamic Sources: PyNotifier, PySourceAdapter, sync/async bridging, field-tier plumbing, and embedded ordered-notify/asyncio unit tests |
ioc.rs | 366 | Tier 3: spvirit.ioc.* record constructors, PyRecordSpec, and spvirit.Ioc (the IOC engine/store) |
channel.rs | 636 | spvirit.lowlevel.Channel — persistent single-PV TCP connection |
codec.rs | 500 | 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 |
monitor_update.rs | 88 | spvirit.lowlevel.MonitorUpdate — value + changed/overrun dotted paths handed to monitor callbacks; dispatch_monitor_update |
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:324–331—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.source.rs:250–284—Notifier.notifydelivers monitor updates synchronously and in order. The in-runtime branch used tohandle.spawn(...)the delivery fire-and-forget, which letnotify(v2)overtakenotify(v1)(computing v2's changed-bit delta against the wrong snapshot) and silently dropped delivery panics. It now blocks on delivery — viablock_in_placewhen already on a runtime worker, plainblock_onotherwise — so each call returns only once its update has been applied. Covered by the ordered-notify tests at the foot ofsource.rs.
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: constructor is fully keyword-only —Server(*, pvs=None, ioc=None, db_file=None, db_string=None, sources=None, port=None, udp_port=None, listen_ip=None, advertise_ip=None, compute_alarms=None, beacon_period=None)(server.rs:695).ioc=takes aspvirit.Ioc(tier 3);db_file=/db_string=load.dbtext as ordinary tier-2 records (deliberately not routed through the IOC engine, so an existing user's records are not silently upgraded to a self-processing IOC).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:840); 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. Monitor callbacks now receive aMonitorUpdate, not the raw value — bothmonitorandsubscribe(andChannel.monitor) route the callback throughmonitor_update::dispatch_monitor_update(client.rs:370, 449). The callback gets.value(the decoded value, as before), plus.changed/.overrun(dotted field paths) and.has_overrun; returningFalseunsubscribes, any other/Nonereturn continues.MonitorUpdate(spvirit.lowlevel.MonitorUpdate, frozen): built byPyMonitorUpdate::from_updatefrom aspvirit_client::MonitorUpdate. Itschanged/overrungetters returnVec<String>copies of the resolved paths (the underlying decoded-value bitsets). A tier-3 change to back the paths withArc<[String]>is deferred and not implemented — the class hands out plainlist[str].- 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:223(source.rs:44 imports it) and shared with the rest of the typed API. - Tier 3 (
ioc.rs): the IOC layer.spvirit.ioc.ai/ao/bi/bo/longin/longoutbuild aRecordSpec(PyRecordSpec) using verbatim EPICS field names (EGU,PREC,DESC,DRVL/DRVH,HIHI, …) passed as**fields, not tier 2'sunits=/drive_limits=spellings — passing a tier-2 spelling raisesValueError(ioc.rs:79). A spec is pending until handed tospvirit.Ioc(records=[...])(ordb_file=/db_string=, exactly one); before binding its handle methods raiseRuntimeError("Unbound"). Once bound,rec.get()/rec.set(v)/rec.set_async(v)andrec["EGU"](field read) work;rec["FIELD"] = …andrec.on_putalways raise — field writes are deferred to sub-project B, and tier-3 records permanently excludeon_put(it would run Python insideprocess()under a lock set).Iocexposesrecord_names()andrun(*, port=None, udp_port=None)(aServer(ioc=self).run()shortcut);add_recordalways raises (records are fixed at build). A bound spec belongs to exactly one engine — reusing it raisesRuntimeError.
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:167):bool→Bool,int→I64,float→F64,str→Str;py_to_scalar_array(convert.rs:189):bytes→U8, empty list →F64, otherwise sniffs the first element. - Typed (strict) path (convert.rs:380 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
-
Some Rust
#[cfg(test)]unit tests exist — notably the asyncio-bridge race test and the ordered/synchronous-notify tests at the foot ofsource.rs. They need a real embedded interpreter, so they run only under the test-only feature:cargo test -p spvirit-py --no-default-features --features test-embed. The rest of the handle-plumbing tests live inspvirit-server(cargo test -p spvirit-server pv::). -
Python tests (
tests/, ~1,500 LOC total) — plain-assert scripts, not pytest; functions namedtest_*collected by amain()loop, run directly with the venv's python aftermaturin develop:test_pv_handles.py(422 LOC) — typed handles.test_value_types.py(341 LOC) — the typed-conversion layer:NtScalar/NtScalarArray/NtTable/NtNdArraytype=/types=,spvirit.scalar(),server.pv()on long/unsigned records, strict coercion errors.test_ioc_host_api.py(200 LOC) — tier-3spvirit.Ioc/spvirit.ioc.*host API.test_lifecycle_events.py(283 LOC) —on_start/on_eventserver hooks.test_monitor_overrun.py(91 LOC) —MonitorUpdate.changed/overrun.test_source_fields.py(160 LOC) — dynamic-source field tiers.
Each server test uses a unique port pair from a reserved range (e.g.
test_pv_handles.py: 15075–15206;test_value_types.py: 16060–16099). -
Examples: ~38 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_complete_ioc.py/demo_db_file.py(tier-3 IOC), anddemo_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.
Crate audit + remediation, 2026-08-29 (landed on branch
audit-remediation-2026-08-29, not yet onmain). A workspace-wide adversarial audit — one pass per crate — produced fixes across the high and medium tiers, since committed on that branch. Load-bearing landings: the server monitor writer is now the two-lane flat-combiningConnWriter(spvirit-server/src/conn_writer.rs), which coalesces monitor frames on a latest-per-ioid lane and keeps control replies on a lossless FIFO lane, building self-contained per-subscriber frames (the stored delta serves only as a change detector); client-supplied pipeline windows (nfree) are now clamped toMAX_PIPELINE_WINDOW = 4096at every assignment from client input (serverhandler.rsandmonitor.rs, and thespserverbinary); PythonNotifier.notifydelivers monitor updates in order rather than via fire-and-forget spawns;spvirit-calcuseswrapping_remto avoid ani32::MIN % -1panic; and the IOC layer prunes deadfield_subs. Reconcile againstgit logbefore relying on any of this.
Codec-gaps effort, 2026-08-07 (landed). Three of the protocol/codec entries below are struck through: segmentation reassembly, the heuristic monitor bitset ordering, and silent array truncation. Delivered as a 10-task TDD plan with per-task commits (
241f4f4..ef7d587), the same shape as Efforts A and B. New codec surface:error.rs(DecodeError/DecodeResult),segment.rs(SegmentReassembler),monitor.rs(MonitorUpdate,MonitorLayout),DecodeLimitsonPvdDecoder, andDecodeModeonPvaOpPayload::decode_with_field_desc. Client, server, spserver, the tools and the Python bindings are all on the new APIs. Breaking:PvdDecoderreturnsDecodeResultrather thanOption, and monitor callbacks receive a&MonitorUpdaterather than a bare value. What is not done: segment emission — see roadmap item 6.
Protocol/codec
- No TLS anywhere (top roadmap item in README).
No segmentation reassembly in the codec (consumers roll their own)— fixed by the codec-gaps effort:spvirit-codec/src/segment.rs'sSegmentReassembler, driven by the client transport, the server handler and spserver. Emission is still missing (roadmap item 6).Monitor bitset overrun ordering is heuristic (three variants + scoring)— fixed:decode_monitor_updateis spec-exact (changed bitset, data, overrun bitset) and is what every workspace call site uses. The scoring heuristic survives only behindDecodeMode::Lenient, for out-of-tree packet-capture consumers.- No packet-capture regression corpus; four duplicated size codecs.
Array decode caps silently truncate; string/struct truncation can desync— fixed: over-limit counts areDecodeError::ArrayTooLargeand counts that cannot fit the remaining buffer areDecodeError::CountExceedsBuffer, both raised before allocation. Limits are configurable viaDecodeLimits.
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. - Segmentation emission in the encoder. Reassembly landed in
spvirit-codecwith the codec-gaps effort above, but nothing in the workspace splits an oversized outgoing message into segments — the server sends one large frame regardless of size. Needed before spvirit can serve large NTNDArray images to clients that expect segmented delivery, and it would also let the reassembler be tested end-to-end against our own server instead of only against synthetic buffers.
The record-processing engine
spvirit-ioc loads a .db file and processes its records the way an EPICS
IOC does: input links pull, forward links push, alarms accumulate over a
pass and commit at its end.
Loading a database
fn main() -> Result<(), String> { use std::sync::Arc; use spvirit_ioc::IocSource; use spvirit_server::PvaServer; let ioc = Arc::new(IocSource::from_db_file("plant.db")?); for line in ioc.graph().report() { eprintln!("{line}"); } let server = PvaServer::builder() .ai("PV:DIRECT", 0.0) .ioc(ioc.clone()) .build(); let _ = server; Ok(()) }
spvirit-ioc depends on spvirit-server, never the reverse, so the engine
cannot reach into the builder. The seam is the other way round:
spvirit-server declares the StoreSource trait and the
PvaServerBuilder::ioc method, and the engine implements the trait. That
also lets the builder check at construction time that the engine's records
and the builtin store's do not overlap — see
Two stores, one server below.
Registering through .ioc() rather than .source() is what makes
<record>.<FIELD> resolve for engine records and what enables the
disjointness check. A .source() registration still works and is the right
call for anything that is not a record store.
Driving the engine from host code
Records can be built without a .db file. The two paths converge before any
field is interpreted — a RecordSpec lowers to the same DbRecord the parser
produces — so they cannot drift:
#![allow(unused)] fn main() { use spvirit_ioc::{IocSource, RecordSpec}; use spvirit_ioc::alarm::Severity; let ioc = IocSource::from_records(vec![ RecordSpec::ao("RIG:SP").out("RIG:RBV.VAL").flnk("RIG:RBV"), RecordSpec::ai("RIG:RBV").inp("RIG:SP PP").egu("C") .hihi(100.0).hhsv(Severity::Major), ])?; }
Writing from the host processes the chain and notifies monitor clients:
#![allow(unused)] fn main() { ioc.set_value("RIG:SP", DecodedValue::Float64(95.0)).await?; }
set_value rather than Source::put matters. put returns its events for the
handler to publish; a write that starts in host code never reaches the
handler, so put alone would process correctly and notify nobody.
Two things the engine will not do:
- Records cannot be added after the engine is built.
RecordIdis{set, index}, assigned by partitioning the link graph into lock sets; a late record can join two sets and invalidate every outstanding id. Base has the same restriction —dbLoadRecordsafteriocInitis unsupported. - There is no
on_put. A callback would run arbitrary code insideprocess()while a lock set is held. Behaviour belongs in the record graph; if you need a callback, use a tier 2 PV, which has no lock sets.
Two stores, one server
A PvaServer can carry two independent record stores at once: the builtin
store (.ai(), .ao(), .db_file() and friends) and, optionally, an
engine registered through .ioc(). Three rules govern how they and any
other sources interact:
- The two stores must be disjoint.
build()panics, naming every colliding record, if the builtin store and the engine store both claim the same name. A.scan,.link, oron_putcallback that names an engine record is the same class of mistake — those callbacks drive the builtin store's direct-write semantics and would never fire against an engine record — sobuild()panics on those too. - Ordinary sources may legally shadow either store. A source registered
with
.source()ahead of a store in resolution order can override one of its PVs on purpose; the registry logs a warning the first time a client searches for the shadowed name, but does not fail. This is the difference between the two tiers: stores are static, enumerable, and must be disjoint from each other; sources are dynamic, ordered, and may legally shadow. - Field PVs follow the winning store.
<record>.<FIELD>resolves through whichever store claims<record>, so a client cannot tell which store served a PV except by the differences documented in the A2 spec's "Deviations" section.
Registration order is builtin at 0, the engine (if any) at 5, and the
record-fields tier-2 (SimplePvStore) field source at 10.
What it processes
Sub-project A covers ai, ao, bi, bo, longin and longout. Loading
a .db containing any other record type is an error naming the record —
silently ignoring records is how a database appears to work while half of it
does nothing.
Lock sets
Records joined by db links — INP, OUT, DOL, FLNK, SDIS — share a
lock set, and a lock set is processed by one thread at a time. This is what
makes a chain of links atomic: a client cannot observe the chain half
updated. ioc.graph().lock_sets shows the partitioning.
Load-time diagnostics
graph().report() returns one line per finding, or nothing at all:
- unreachable — a record that is
SCAN = Passive,PINI = NO, and is not the target of any PP or FLNK link. It will never process. - cycles — a loop in the link graph. Not an error:
PACTbreaks it at runtime. Reported because a cycle is usually a mistake. - fan-out — a record that more than ten other records' link fields name,
i.e. an inbound degree:
graph().high_fan_outcounts records linked to by a record, not links a record itself holds. One pass over such a record fans out across the database. - unresolved — a link naming a record this database does not contain. It reads as a constant zero.
Not yet implemented
Scanning is sub-project B: SCAN = 1 second parses and appears in the
diagnostics, but nothing drives it yet. PINI records process when you call
process_pini(). Channel-access links (CA, CP, CPP) are sub-project C,
and every other record type is sub-project D.