Spvirit
/ˈspɪrɪt/ of the Machine
Spvirit is a Rust library for working with EPICS PVAccess protocol, including encoding/decoding and connection state tracking. It also includes tools for monitoring and testing PVAccess connections. These are not yet production ready , but they are available for anyone to use and contribute to.
Key areas of development in the near future include:
- More complete support for EPICS Normative Types (NT) and their associated metadata.
- Expanding
spvirit-serverwith more complete softIOC behaviours and record processing.
Why Rust?
Because why not, admittedly I just wanted to learn Rust and this seemed like a fun project with a moderately useful outcome.
Project Structure
The project is structured as a Cargo workspace with five crates:
spvirit-types: Shared data model types for PVAccess Normative Types (NT).spvirit-codec: PVAccess protocol encoding/decoding logic and connection state tracking.spvirit-client: Client library — search, connect, get, put, monitor.spvirit-server: Server library — db parsing, PV store trait, PVAccess server runtime.spvirit-tools: Command-line tools (CLI binaries) for monitoring and testing PVAccess connections.
Key Concepts
If you're new to EPICS or PVAccess, the terminology can be confusing. This section explains the key concepts and how they map to Spvirit's Rust types.
PVs, Records, and .db files
In EPICS, a Process Variable (PV) is a named data point — a temperature reading, a motor position, or a shutter state. PVs are the things clients read and write over the network.
On the server side, each PV is backed by a Record. A record has a type (ai, ao, bi, bo, waveform, etc.) that determines its behaviour: whether it's read-only or writable, what data shape it holds, and what processing it does.
Records are usually declared in .db files — plain-text configuration files using the 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 this — the record type, the current value (as a Normative Type), and the common fields. You can either build records in code with PvaServer::builder().ai(...) or load them from .db files with .db_file("path.db").
flowchart LR
DB[".db file"] -->|parse_db| RI["RecordInstance"]
Code["builder.ai(...)"] --> RI
RI --> Store["SimplePvStore"]
Store --> Server["PvaServer"]
Server -->|PVAccess protocol| Client["PvaClient"]
Record Types at a Glance
| 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 |
waveform |
.waveform(name, data) |
Writable | Array | Spectra, traces |
Input records are read-only from the client's perspective — values are produced by the server (scan callbacks, hardware, simulation). Output records accept writes from PVAccess clients (PUT operations).
Normative Types (NT)
The PVAccess protocol doesn't send plain numbers — it sends structured payloads called Normative Types. These wrap the actual value with rich metadata (alarm state, timestamp, display limits, engineering units, etc.).
flowchart TD
NTP["NtPayload"]
NTP --> NTS["NtScalar"]
NTP --> NTSA["NtScalarArray"]
NTP --> NTT["NtTable"]
NTP --> NTNA["NtNdArray"]
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"]
The four Normative Types in Spvirit:
| Normative Type | Rust Type | Backed by | Used for |
|---|---|---|---|
| NTScalar | NtScalar |
ScalarValue (f64, i32, bool, String, …) |
Single-value PVs (ai, ao, bi, bo, etc.) |
| NTScalarArray | NtScalarArray |
ScalarArrayValue (Vec<f64>, Vec<i32>, …) |
Array PVs (waveform, aai, aao) |
| NTTable | NtTable |
Named columns of ScalarArrayValue |
Tabular data |
| NTNDArray | NtNdArray |
ScalarArrayValue + dimensions + attributes |
Image / detector data (areaDetector) |
Enums in EPICS (bi/bo and ZNAM/ONAM)
EPICS doesn't have a first-class enum type like Rust. Instead, binary records (bi/bo) use two string labels — ZNAM (the "zero" name) and ONAM (the "one" name) — to map a boolean value to human-readable choices:
record(bo, "SHUTTER:CTRL") {
field(ZNAM, "Closed")
field(ONAM, "Open")
}
When a client reads this PV, the NTScalar's value is the integer index (0 or 1), and the display.form.choices field carries ["Closed", "Open"] so the UI can show a dropdown. In Spvirit, bi/bo records store the underlying value as ScalarValue::Bool and the labels in the znam/onam fields of RecordData::Bi / RecordData::Bo.
How it all fits together
flowchart TD
subgraph Server Side
DB[".db file"] -->|load_db / parse_db| Records["HashMap<String, RecordInstance>"]
Builder["PvaServer::builder()
.ai() .ao() .bo() ..."] --> Records
Records --> Store["SimplePvStore
(implements PvStore 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 Client Side
PC["PvaClient::builder().build()"]
PC -->|pvget| Runtime
PC -->|pvput| Runtime
PC -->|pvmonitor| Runtime
PC -->|pvinfo| Runtime
end
subgraph Wire Format
Runtime <-->|"PVAccess TCP/UDP
(spvirit-codec)"| PC
end
style Server Side fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Client Side fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Wire Format fill:#0f3460,stroke:#16213e,color:#e0e0e0
Getting Started
Install Rust
# Linux
|
clone the repo
Build the project
Run the tools
# or
# or if installed
Using the library in your own Rust project
Add the crates you need to your Cargo.toml:
[]
= "0.1" # client library: search, connect, get, put, monitor
= "0.1" # server library: db parsing, PvStore trait, PVA server
= "0.1" # low-level PVA protocol encode/decode
= "0.1" # shared Normative Type data model
= "0.1" # all of the above + CLI tool helpers
Fetching a PV value (pvget)
use PvaClient;
async
Writing a value to a PV (pvput)
use PvaClient;
async
Monitoring a PV for live updates (pvmonitor)
use ControlFlow;
use PvaClient;
async
Running a PVAccess server
use PvaServer;
async
Reacting to client writes (on_put)
Register a callback that fires whenever a PVAccess client writes to a PV:
use PvaServer;
async
Reading and writing PVs at runtime (store())
The store() handle lets your own code read and write PV values while the server is running — useful for simulation loops, hardware I/O, or responding to external events:
use PvaServer;
use ScalarValue;
async
Periodic scan callbacks
Use .scan() to produce new values on a timer — the server pushes updates to any monitoring clients automatically:
use PvaServer;
use ScalarValue;
use ;
use Duration;
static TICK: AtomicU64 = new;
async
Serving a waveform (array PV)
use PvaServer;
use ;
async
Building a custom record by hand
For record types not covered by the builder helpers, construct a RecordInstance directly and insert it into the store:
use HashMap;
use ;
use ;
async
Running the examples
All examples live in spvirit-client/examples/ and spvirit-server/examples/ and can be run directly from the repo:
# Client examples (need a running PVAccess server on the network)
# Server examples (start a PVAccess server on localhost:5075)
Quick demo — server + client in two terminals:
# Terminal 1: start a server
# Terminal 2: read a PV from it
Tools available
| spvirit tool | EPICS Base equivalent | Description |
|---|---|---|
spget |
pvget |
Fetch the current value of a PV |
spput |
pvput |
Write a value to a PV |
spmonitor |
pvmonitor |
Subscribe to a PV and print value changes |
spinfo |
pvinfo |
Display field/metadata information for a PV |
splist |
pvlist |
List all available PVs on discovered servers |
spserver |
softIoc |
Not fully one-to-one - just a demo, it does parse some db file vocab |
spexplore |
Interactive TUI to browse servers, select PVs, and monitor values | |
spsearch |
TUI showing PV search network traffic for diagnostics | |
spsine |
Continuously write a sine wave to a PV (demo/testing) | |
spdodeca |
Server publishing a rotating 3D dodecahedron as an NTNDArray PV |
Server (softIOC-like experiment)
The spvirit-server crate provides a reusable PVAccess server runtime at two levels:
- High-level: Use
PvaServer::builder()to declare typed records (ai,ao,bi,bo,string_in,string_out,waveform), registeron_putcallbacks, attach periodicscancallbacks, load.dbfiles, and call.run(). See the "Running a PVAccess server" example above. - Low-level: Implement the [
PvStore] trait to supply your own PV data source, then callrun_pva_serverto serve PVs over PVAccess. The bundledspserverCLI tool demonstrates this by parsing a limited subset of EPICS.dbfile syntax to serve static PVs.
Both levels prove that the encoding/decoding and connection handling logic in spvirit-codec is sufficient to implement a server, and they can be used as a starting point for a more full-featured softIOC in the future. (hint hint PRs welcome :))
Integration test matrix
I have tested the tools in this repo against the following EPICS PVAccess servers:
- EPICS
- p4p (pvxs under the hood)
- PvAccessJava
Related Projects
- spvirit-scry — A Rust tool for capturing and analyzing pvAccess EPICS packets.
References
I used the following libraries and repos as refernce materials for PVAccess protocol:
GenAI Usage Log
| Section / Area | What Was Done With AI | Plans Ahead |
|---|---|---|
spvirit-types |
Hand coded, few types completed with AI, the prettified with AI | keep the same, fairly complete |
spvirit-codec |
Most was hand-coded, some restructuring and prettifying was done with AI. | keep the same, bring in any common helpers, maybe write a siplified API for users |
spvirit-tools |
Mostly AI generated, manually coded parts of Put and Get then let the Agents build on top. Client and server logic has been split out into spvirit-client and spvirit-server crates. |
The APIs are now split idiomatically. Continued refinement of high-level convenience functions for put and monitor. |
PvaClient / PvaServer |
High-level builder-pattern APIs (PvaClient::builder(), PvaServer::builder()) designed with AI assistance. Wraps protocol-level operations into ergonomic one-liners for get, put, monitor, info, and typed server records. |
Extend with more record types, structured put payloads, and TLS support. |
| Testing | I wrote some basic tests, then used GenAI agents to generate more tests and test cases, which I then manually curated and edited. | Suite is fairly comprehensive so I will keep it as is. |