Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

How the site is laid out

API reference

This book is the tutorial. The exhaustive, generated API documentation lives on docs.rs, one page per crate:

CrateAPI docs
spvirit-typesdocs.rs/spvirit-types
spvirit-codecdocs.rs/spvirit-codec
spvirit-clientdocs.rs/spvirit-client
spvirit-serverdocs.rs/spvirit-server
spvirit-toolsdocs.rs/spvirit-tools
spvirit-calcdocs.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

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 · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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 toGo to
Report a bug, or ask a questionGitHub issues
Work out why a PV will not connectTroubleshooting
Check whether something is unimplemented rather than brokenKnown gaps
Look up a Rust APIdocs.rs, or the crate map
Look up a Python APIPython 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:

  1. Chapters cite code; they do not copy it. Every code block in this book is an {{#include}} pointing at a real file under spvirit-*/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.

  2. docs/book/verify.toml is the manifest. Each chapter declares the example files, anchors, and CLI tools it cites. spvirit-tools/tests/docs_verify.rs checks 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_verify
    

    The "✅ 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
    
  3. 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 · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What is Spvirit?

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, 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.

CrateWhat it is
spvirit-typesShared data model for PVAccess Normative Types. Pure data, no I/O.
spvirit-codecProtocol encoding/decoding and connection state tracking.
spvirit-clientSearch, connect, get, put, monitor.
spvirit-server.db parsing, the Source trait, the PVAccess server runtime.
spvirit-toolsThe command-line tools, and the integration test suites.
spvirit-pyPython 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 Source trait.
  • 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

EPICS in 10 minutes

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, 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 typeai, 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:

  • i means input, o means 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.
  • a means analog (a float), b means binary (a bool), mbb means multi-bit binary (an enum), long means 32-bit integer, and waveform/aai/aao are 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 typeRust builderDirectionData shapeTypical use
ai.ai(name, f64)Input (read-only)ScalarSensor readings
ao.ao(name, f64)Output (writable)ScalarSetpoints, commands
bi.bi(name, bool)Input (read-only)BooleanStatus bits
bo.bo(name, bool)Output (writable)BooleanOn/off switches
stringin.string_in(name, str)Input (read-only)StringStatus messages
stringout.string_out(name, str)Output (writable)StringText commands
longin(handle only)Input (read-only)Integer32-bit counters
longout(handle only)Output (writable)Integer32-bit settings
waveform.waveform(name, data)WritableArraySpectra, traces
aai.aai(name, data)Input (read-only)ArrayRead-only array data
aao.aao(name, data)Output (writable)ArrayWritable array data
subArray.sub_array(name, data)WritableArrayView into part of an array
mbbi.mbbi(name, choices, idx)Input (read-only)EnumMulti-choice status
mbbo.mbbo(name, choices, idx)Output (writable)EnumMulti-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.

ConstructRust builderNormative TypeTypical use
NtTable.nt_table(name, table)NTTableTabular data
NtNdArray.nt_ndarray(name, arr)NTNDArrayImage / detector data
generic.generic(name, desc, payload)anyCustom 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, and generic are builder-only — there is no typed Pv<T> handle for them.
  • longin/longout are the reverse: they exist as typed handles only (Pv::longin(name, i32)), not as builder methods, and .db loading for them is not wired up — spvirit-server/src/db.rs carries a TODO saying so. Declaring a longin record in a .db file 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.

FieldMeans
DESCDescription
EGUEngineering units (degC, mm, counts)
PRECDisplay precision (decimal places)
HOPR / LOPRHigh / low operator display range
DRVH / DRVLDrive high / low limit — clamps writes
HIHI / HIGH / LOW / LOLOAlarm thresholds
MDEL / ADELMonitor / archive deadbands
ZNAM / ONAMZero 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 .db file.

Record-specific fields set programmatically are not served. If you build a PV with spvirit.ai("X", 22.5, units="degC", prec=2) or Pv::ai(...).units("degC"), then X.EGU and X.PREC do not resolve — the client times out, because the channel does not exist. The same record loaded from a .db with field(EGU, "degC") serves X.EGU fine.

Verified on both paths against spget. The cause is in spvirit-server/src/record_fields.rs: field_value consults record.raw_fields, which is populated by the .db parser and left empty by the builder and handle APIs. The metadata is still present in the NTScalar's display structure either way — only the separate .FIELD channel 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&lt;String, RecordInstance&gt;"]
        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, subArray and 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 · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

PVAccess does not send plain numbers. It sends structured payloads called Normative Types (NT), which wrap the value together with its alarm state, timestamp, display limits, engineering units, and control metadata — all in one message.

This is the single biggest difference from Channel Access, and it is why a pvget shows you a timestamp and a severity without a second round trip.

flowchart TD
    NTP["NtPayload"]
    NTP --> NTS["NtScalar"]
    NTP --> NTSA["NtScalarArray"]
    NTP --> NTT["NtTable"]
    NTP --> NTNA["NtNdArray"]
    NTP --> NTE["NtEnum"]

    NTS --> V1["value: ScalarValue"]
    NTS --> A1["alarm severity/status/message"]
    NTS --> D1["display: limits, units, precision"]
    NTS --> C1["control: limits, min_step"]
    NTS --> VA1["valueAlarm: thresholds"]

    NTSA --> V2["value: ScalarArrayValue"]
    NTSA --> A2["alarm"]
    NTSA --> D2["display"]

    NTT --> L["labels + columns"]
    NTNA --> DIM["dimensions + codec + attributes"]
    NTE --> V3["index: u32 + choices: Vec&lt;String&gt;"]
    NTE --> A3["alarm"]

The five types

Normative TypeRust typeBacked byUsed for
NTScalarNtScalarScalarValue (f64, i32, bool, String, …)Single-value PVs (ai, ao, bi, bo, …)
NTScalarArrayNtScalarArrayScalarArrayValue (Vec<f64>, Vec<i32>, …)Array PVs (waveform, aai, aao)
NTEnumNtEnumindex (u32) + choices (Vec<String>)Multi-bit binary records (mbbi, mbbo)
NTTableNtTableNamed columns of ScalarArrayValueTabular data
NTNDArrayNtNdArrayScalarArrayValue + dimensions + attributesImage / detector data (areaDetector)

All five live in spvirit-types, and the enum that unifies them is NtPayload. Everything the server sends and the client receives is one of these.

What is inside an NTScalar

The value is the small part. An NtScalar also carries:

  • alarm — severity, status, and a message string. Severity is the familiar EPICS ladder: NO_ALARM, MINOR, MAJOR, INVALID.
  • timeStamp — seconds past epoch, nanoseconds, and a user tag. The server stamps this automatically for IOC-style records.
  • display — units (EGU), precision (PREC), display limits (HOPR/LOPR), a description, and for binary records the form.choices list that carries ZNAM/ONAM.
  • control — drive limits (DRVH/DRVL) and a minimum step.
  • valueAlarm — the HIHI/HIGH/LOW/LOLO thresholds 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 · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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 recordsRaw NT payloads
You create them withPv<T> handles (Pv::ai(...) …), builder methods (.ai(), .waveform() …), .db filesNtScalar/NtScalarArray/… built by hand; hand-built RecordInstance; custom Source impls
You read/writeplain values: pv.set(21.5), store.set_value(...)whole payloads: store.put_nt(...) / get_nt(...), Notifier posts
Alarm statecomputed for you from HIHI/HIGH/LOW/LOLO limits (compute_alarms), or pv.set_alarm(...)you set alarm on every payload yourself
Timestampsstamped automatically on every update, client PUT includedyours 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 servernot applied — every put_nt/notify posts
Best forsoft IOCs, simulators, anything that should feel like an EPICS recordgateways/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 · docs-verify

The badge reports the whole docs-verify suite, 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.

Your first PV

Verified · simple_server.rs · pvget.rs · demo_first_pv.py · demo_get.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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 blockingclient.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 · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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.

Reading and writing

Verified · pvget.rs · pvput.rs · demo_get.py · demo_put.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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.

Monitoring changes

Verified · pvmonitor.rs · demo_monitor.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What you'll build

A subscription: one request, then updates pushed by the server for as long as you care to listen. This is what PVAccess is actually for — polling a PV in a loop is almost always the wrong answer.

Rust

#![allow(unused)]
fn main() {
    let client = PvaClient::builder().build();
    let cb = |value: &spvirit_codec::spvd_decode::DecodedValue| {
        println!("{value}");
        ControlFlow::Continue(())
    };
    let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
    if let Some(q) = pipeline {
        client
            .pvmonitor_with_options(&pv, &refs, MonitorOptions::pipelined(q), cb)
            .await?;
    } else if fields.is_empty() {
        client.pvmonitor(&pv, cb).await?;
    } else {
        client.pvmonitor_fields(&pv, &refs, cb).await?;
    }
}

The callback returns a ControlFlow: Continue(()) to keep going, Break(()) to unsubscribe. pvmonitor runs until the callback breaks or the connection drops.

MonitorOptions::pipelined(q) asks the server for flow control with a queue depth of q — useful on high-rate PVs where a slow consumer would otherwise fall behind.

Python

client = spvirit.Client()

seen = 0


def on_update(value):
    global seen
    seen += 1
    print(f"{seen}: {value['value']:.3f}")
    return seen < 5  # returning False ends the monitor


client.monitor("SIM:TEMPERATURE", on_update)

client.monitor(...) blocks until the callback returns False or raises. For a non-blocking version use client.subscribe(...), which returns a Subscription you can close(), and which runs the callback on a background thread:

sub = client.subscribe("SIM:TEMPERATURE", on_update)
...
sub.close()

If a subscription ends on a network error, sub.error holds the message and sub.is_active becomes False — worth checking, because a silently dead subscription looks exactly like a quiet PV.

From the command line

$ spmonitor SIM:TEMPERATURE

What to notice

The first update is the current value. A subscription delivers the present state immediately, then changes. You do not need a GET before a monitor.

Monitors respect the record's MDEL. This is the single most useful thing on this page. A record with mdel=1.0 posts an update only when the value has moved at least 1.0 from the last posted value — not from the last set value. Writing 0.1, 0.2, 0.3, 5.0, 5.1, 5.2, 20.0 to such a PV delivers three updates:

posted to monitor: [0.0, 5.0, 20.0]

0.0 is the initial value on subscribe; 5.0 and 20.0 each cleared the deadband. The intermediate writes landed in the record — a GET would show 5.2 — they just were not broadcast.

MDEL defaults to 0, meaning no deadband. A record you never gave an mdel posts every change. On a 1 kHz PV with a hundred subscribers, that is a decision, so make it deliberately.

Severity changes always get through. The deadband is bypassed when the alarm severity changes, so a PV crossing into MAJOR is never silently swallowed by a large MDEL.

ADEL is not the same thing. ADEL is the archive deadband; it is parsed, stored, and served over field access, but PVAccess monitors use MDEL.

Deadbands are a record-level feature. A raw-NT source posting with put_nt has no MDEL to consult, and every post goes out. See Records vs raw NT.

Run it

# Terminal 1
python spvirit-py/examples/demo_scan.py

# Terminal 2
cargo run -p spvirit-client --example pvmonitor -- SIM:TEMPERATURE
# or
python spvirit-py/examples/demo_monitor.py
# or
spmonitor SIM:TEMPERATURE

demo_scan.py updates ten times a second, so terminal 2 fills immediately:

$ spmonitor SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-06 09:25:29.221 21.677613
SIM:TEMPERATURE 21.734644
SIM:TEMPERATURE 21.8092
SIM:TEMPERATURE 21.89275
SIM:TEMPERATURE 21.956506
SIM:TEMPERATURE 22.051046
SIM:TEMPERATURE 22.151852
SIM:TEMPERATURE 22.240413
SIM:TEMPERATURE 2026-08-06 09:25:30.028 22.347997
SIM:TEMPERATURE 22.440441
SIM:TEMPERATURE 22.534399
...

spmonitor prints the timestamp only on the first update of each wall-clock second — that is a display convenience, not a change in the data. Every update carries a full timestamp on the wire. Counting the lines between two timestamps is a quick way to see your actual update rate.

Stop it with Ctrl-C.

Next

Discovery and introspection.

Discovery and introspection

Verified · pvlist.rs · demo_discovery.py · demo_pvfind.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What you'll build

Four questions, in the order you actually hit them: what servers are on this network, which one has my PV, what else does it serve, and what shape is that PV? These are the library forms of splist and spinfo — the same four calls those tools make.

Discovery, listing, and introspection are metadata-only — none of the Rust steps below reads a value. The Python introspection snippet does end with a get() to show the current value alongside the field table, but that call is separate from introspect() itself.

Rust

Find servers. A UDP search with no PV name attached: every PVA server that hears it answers with its GUID and TCP address.

#![allow(unused)]
fn main() {
            let servers = discover_servers(udp_port, timeout, &targets, false).await?;
            for server in &servers {
                let guid: String = server.guid.iter().map(|b| format!("{b:02X}")).collect();
                println!("GUID 0x{guid}  tcp {}", server.tcp_addr);
            }
}

build_search_targets(None, None) produces the target list the way EPICS Base does — EPICS_PVA_ADDR_LIST merged with auto-discovered broadcast addresses, unless EPICS_PVA_AUTO_ADDR_LIST disables the latter. Pass Some(ip) as the first argument to pin a single target.

Locate a PV. The same search, narrowed to one name:

#![allow(unused)]
fn main() {
            let server_addr = search_pv(&pv, udp_port, timeout, &targets, false).await?;
            println!("{pv} is served by {server_addr}");
}

List a server's PVs. This one takes a SocketAddr, not a name — which is why the two steps above come first:

#![allow(unused)]
fn main() {
            let client = PvaClient::builder().timeout(timeout).build();
            let (names, source) = client.pvlist_with_fallback(server_addr).await?;
            println!("{} PVs via {source:?}", names.len());
            for name in &names {
                println!("  {name}");
            }
}

Describe a PV. This step builds its own PvaClient and resolves the PV name directly, so it does not need the address search_pv found above. pvinfo returns a StructureDesc; format_structure_tree renders it the way spinfo does:

#![allow(unused)]
fn main() {
            let client = PvaClient::builder().timeout(timeout).build();
            let desc = client.pvinfo(&pv).await?;
            println!("{}", format_structure_tree(&desc));
}

Python

The same four beats. Discovery and listing live in spvirit.lowlevel:

    print("\ndiscover_servers(timeout=1.5) ...")
    try:
        servers = ll.discover_servers(timeout=1.5)
    except Exception as e:  # noqa: BLE001
        print(f"  discovery failed: {e}")
        servers = []
    for s in servers:
        print(f"  guid={s['guid']}  addr={s['addr']}")
    if not servers:
        print("  (no servers responded — run a spserver locally to see results)")
    if len(sys.argv) > 1:
        pv = sys.argv[1]
        print(f"\nsearch_pv({pv!r}, timeout=2.0) ...")
        try:
            addr = ll.search_pv(pv, timeout=2.0)
            print(f"  -> {addr}")
        except Exception as e:  # noqa: BLE001
            print(f"  not found: {e}")

This continues straight from discovery, reusing the servers list that discover_servers returned:

    if servers:
        addr = servers[0]["addr"]
        print(f"\npvlist({addr!r}) ...")
        try:
            names, source = ll.pvlist(addr, timeout=3.0)
            print(f"  via {source}: {len(names)} names")
            for n in names[:10]:
                print(f"    {n}")
        except Exception as e:  # noqa: BLE001
            print(f"  pvlist failed: {e}")

Introspection goes through a channel. demo_pvfind.py walks the returned description with a small recursive walk() helper — defined just above this block in the file — that yields one row per field, descending into field.struct_desc wherever a field is itself a structure; formatting each value's type goes through codec, spvirit.codec, imported at the top of the same file:

    with Channel.connect(pv_name, addr) as ch:
        desc = ch.introspect()

        print(f"\nPV        : {pv_name}")
        print(f"server    : {addr}  (sid={ch.sid})")
        print(f"struct_id : {desc.struct_id}")
        print(f"fields    : {len(desc)}\n")

        width = max(len(name) for name, _, _ in walk(desc))
        for name, ftype, is_array in walk(desc):
            suffix = "[]" if is_array and not ftype.endswith("[]") else ""
            print(f"  {name:<{width}}  {ftype}{suffix}")

Channel.introspect() is the low-level route, and the one to use when you want the channel open anyway for a subsequent get(). For a one-shot summary there is also Client.info(pv_name), but it is flatter than introspect()'s result: a top-level dict of {struct_id, fields: [{name, field_type}]}, with no nesting into sub-structures and no is_array flag. Reach for Channel.introspect() when you need the full recursive description; Client.info when a flat top-level summary is enough. Client.pvlist(addr) is the __pvlist-only convenience — it returns just the name list and has none of lowlevel.pvlist's fallback chain, so it fails outright on servers where the fallback would have succeeded. Use lowlevel.pvlist when you need that fallback chain or want to know which route answered.

From the command line

$ splist
$ splist 127.0.0.1:5075
$ spinfo VAC:PRESSURE

What to notice

Listing needs an address, not a name. pvlist takes a SocketAddr because listing is a question about a server, while a PV name is a question about the network. If all you have is a PV name, search_pv (Rust) or lowlevel.search_pv (Python) converts one into the other. Rust also offers resolve_pv_server, which applies the full PvGetOptions — name servers, explicit --server, the lot — rather than a bare broadcast.

The second return value names the route that worked. pvlist_with_fallback tries four strategies in turn and tells you which answered: PvListSource::PvList, GetField, ServerRpc, or ServerGet. Python returns it as the second element of a (names, source) tuple, with source spelled as one of the strings "pvlist", "getfield", "server_rpc", or "server_get" — that is the source the Python snippet above prints. It matters because the routes differ in completeness — a server answering by ServerGet may be giving you a truncated view. The splist page has the detail.

Introspection transfers no data. pvinfo uses CMD_GET_FIELD (0x11), so the server replies with a type description and no value. That makes it safe on PVs a GET would choke on — a 4-megapixel image, or a PV whose read triggers expensive device I/O.

__pvlist is in every listing. It is the server's own introspection channel, not one of your PVs. Filter it out if you are building a UI.

Discovery is a UDP broadcast. On a multi-homed host the search can leave by the wrong interface and find nothing. build_search_targets(Some(ip), None) or EPICS_PVA_ADDR_LIST pins it.

Run it

# Terminal 1 — something to talk to
cargo run -p spvirit-server --example complete_ioc

# Terminal 2
cargo run -p spvirit-client --example pvlist
# or, once you have an address from the line above
cargo run -p spvirit-client --example pvlist -- 127.0.0.1:5075
# or, to describe one PV
cargo run -p spvirit-client --example pvlist -- VAC:PRESSURE
# or, the Python equivalents
python spvirit-py/examples/demo_discovery.py
python spvirit-py/examples/demo_pvfind.py VAC:PRESSURE

Bare pvlist finds servers:

$ cargo run -p spvirit-client --example pvlist
GUID 0x60C70000640AEFC9B42DC918  tcp 10.64.23.134:5075

An address argument lists that server's PVs:

$ cargo run -p spvirit-client --example pvlist -- 127.0.0.1:5075
6 PVs via PvList
  VAC:ERROR
  VAC:LINK
  VAC:PRESSURE
  VAC:RGA
  VAC:SETPOINT
  __pvlist

A PV name searches for it, then describes it:

$ cargo run -p spvirit-client --example pvlist -- VAC:PRESSURE
VAC:PRESSURE is served by 10.64.23.134:5075
struct epics:nt/NTScalar:1.0
value: double
alarm: structure
  severity: int
  status: int
  message: string
timeStamp: structure
  secondsPastEpoch: long
  nanoseconds: int
  userTag: int
display: structure
  limitLow: double
  limitHigh: double
  description: string
  units: string
  precision: int
  form: structure
    index: int
    choices: string[]
control: structure
  limitLow: double
  limitHigh: double
  minStep: double
valueAlarm: structure
  active: boolean
  lowAlarmLimit: double
  lowWarningLimit: double
  highWarningLimit: double
  highAlarmLimit: double
  lowAlarmSeverity: int
  lowWarningSeverity: int
  highWarningSeverity: int
  highAlarmSeverity: int
  hysteresis: ubyte

The GUID and address will differ on your machine.

Next

Reacting to writes.

Reacting to writes

Verified · on_put.rs · on_put_reject.rs · demo_on_put.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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.

Simulating a device

Verified · scan_callback.rs · linked_calc.rs · demo_scan.py · demo_calc.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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
scanlogs the error, re-posts the last value the scan produced (or the type default — 0.0/False/0/"" — if it has never produced one)
calclogs 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.

Arrays and waveforms

Verified · waveform.rs · demo_waveform.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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

BuilderRecordClients may write
.waveform(name, data)waveformyes
.aai(name, data)aaino
.aao(name, data)aaoyes

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.

Enums and binary records

Verified · exotic_nt.rs · demo_enums.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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.

RecordDirectionEPICS field names for choices
bi / boin / outZNAM, ONAM (exactly two)
mbbi / mbboin / outZRSTFFST (up to sixteen)

bi/bo are not NtEnum in spvirit — they are NtScalar booleans, and spget prints true/false. ZNAM/ONAM name the two states in a .db file. If you need the labels on the wire, use mbbi/mbbo.

Rust

#![allow(unused)]
fn main() {
    let server = PvaServer::builder()
        .mbbi(
            "SIM:STATE",
            vec![
                "Idle".to_string(),
                "Running".to_string(),
                "Error".to_string(),
            ],
            0,
        )
        .mbbo(
            "SIM:MODE",
            vec![
                "Standby".to_string(),
                "Acquire".to_string(),
                "Calibrate".to_string(),
            ],
            0,
        )
        .generic(
            "SIM:POSITION",
            "demo:custom/Position:1.0",
            vec![
                ("x".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))),
                ("y".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))),
                (
                    "label".to_string(),
                    PvValue::Scalar(ScalarValue::Str("origin".to_string())),
                ),
            ],
        )
        .build();
}

Python

STATES = ["Idle", "Running", "Fault"]
MODES = ["Standby", "Acquire", "Calibrate"]

# mbbi is read-only over the wire; mbbo accepts client writes.
state = spvirit.mbbi("SIM:STATE", STATES, 0, desc="Machine state")
mode = spvirit.mbbo("SIM:MODE", MODES, 0, desc="Requested mode")

server = spvirit.Server(pvs=[state, mode])
server.start()

# The value is the choice *index*, not the label.
for i in range(len(STATES)):
    state.set(i)
    print(f"SIM:STATE = {i} ({STATES[i]})")
    time.sleep(1)

What to notice

Enum records ignore the scalar metadata options. units, prec, adel, mdel, and the limit setters do not apply — only desc is accepted. An NtEnum has no engineering units to carry.

.db files cannot define enum records. mbbi and mbbo parse, then fail at construction with:

Record 'DEMO:STATE': type 'mbbi' is not a standard EPICS Base record type
and cannot be loaded from .db files

The message is misleading — mbbi is a standard EPICS Base record type; it is spvirit's .db loader that does not build it (spvirit-server/src/db.rs:550). Build enum records in code.

Writing to an mbbo from a client does not work today. The record is advertised writable and the PUT is accepted on the wire — spput prints OK, the Python client's put() returns without raising — but the value does not change:

$ spput SIM:MODE --json '{"value":{"index":2}}'
SIM:MODE OK

$ spget SIM:MODE
SIM:MODE {index=0, choices=["Standby", "Acquire", "Calibrate"]}

The store's enum PUT branch (spvirit-server/src/simple_store.rs:616) matches only a bare integer under value, but the NTEnum wire format nests the index one level deeper as value.index, so the update is dropped silently. Drive enum records server-side with pv.set(index) and treat them as read-only from the client until this is fixed.

Out-of-range indices are rejected, not clamped. The store checks idx < 0 || idx >= choices.len() and leaves the value alone.

Run it

# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_enums.py

# Terminal 2
spget SIM:STATE
spmonitor SIM:STATE
$ spget SIM:STATE
SIM:STATE 2026-08-06 09:14:32.958 {index=0, choices=["Idle", "Running", "Error"]}

$ spinfo SIM:STATE
SIM:STATE:
struct epics:nt/NTEnum:1.0
value: structure
  index: int
  choices: string[]
alarm: structure
  severity: int
  status: int
  message: string
timeStamp: structure
  secondsPastEpoch: long
  nanoseconds: int
  userTag: int

value is a structure, not a number — that is what makes NTEnum different from an NTScalar holding an integer.

Under a monitor the difference shows up on the wire:

$ spmonitor SIM:STATE
SIM:STATE 2026-08-06 09:27:05.724 {index=0, choices=["Idle", "Running", "Error"]}
SIM:STATE 2026-08-06 09:27:06.731 {index=1}
SIM:STATE 2026-08-06 09:27:07.748 {index=2}
SIM:STATE 2026-08-06 09:27:08.763 {index=0}

The choice list arrives once, in the first update, and is then omitted because it did not change. PVAccess sends a change bitset, not a whole structure. A client that only reads the field it was given on each update will lose the labels after the first one — cache them.

Next

Alarms and severity.

Alarms and severity

Verified · alarms.rs · demo_alarms.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What you'll build

PVs that tell a client not just what the value is but whether it should be worried about it.

The alarm structure

Every NtScalar carries an alarm block alongside its value:

alarm:
  severity: int      # 0 NONE, 1 MINOR, 2 MAJOR, 3 INVALID
  status:   int      # EPICS status code
  message:  string   # free text

Severity is the part clients act on. A control screen turns a widget yellow on MINOR and red on MAJOR; an archiver may record the transition even when the value itself is inside the deadband.

Alarm transitions always post. The MDEL deadband gates value changes only — a severity change reaches every subscriber regardless of how small the value moved (spvirit-server/src/simple_store.rs:545).

Two ways to get a severity

HowEvaluated by
ComputedLOW/HIGH/LOLO/HIHI in a .db file, plus .compute_alarms(true)the server, on every write
Explicitset_alarm(severity, status, message)you

Computed, from a .db file

record(ao, "DEMO:SETPOINT") {
    field(LOW,  "10")     # MINOR below this
    field(HIGH, "40")     # MINOR above this
    field(LOLO, "5")      # MAJOR below this
    field(HIHI, "45")     # MAJOR above this
}

With .compute_alarms(true) the server re-evaluates on every write:

$ spput DEMO:SETPOINT 41 && spget DEMO:SETPOINT
DEMO:SETPOINT  41 MINOR READ HIGH

$ spput DEMO:SETPOINT 46 && spget DEMO:SETPOINT
DEMO:SETPOINT  46 MAJOR READ HIHI

$ spput DEMO:SETPOINT 3 && spget DEMO:SETPOINT
DEMO:SETPOINT   3 MAJOR READ LOLO

$ spput DEMO:SETPOINT 25 && spget DEMO:SETPOINT
DEMO:SETPOINT  25

compute_alarms defaults to false. Without it the limits are published but never compared against anything.

Explicit

Rust

#![allow(unused)]
fn main() {
    let pressure = Pv::ao("SIM:PRESSURE", 50.0)
        .units("bar")
        .desc("Vessel pressure")
        // lolo, low, high, hihi — published to clients, not evaluated.
        .alarm_limits(5.0, 10.0, 90.0, 110.0);

    let link = Pv::ai("SIM:LINK", 0.0).desc("Device link health");

    let server = PvaServer::serve([pressure.clone(), link.clone()])
        .compute_alarms(true)
        .build()
        .await;
}
#![allow(unused)]
fn main() {
    // Alarms you decide yourself, independent of the value. This is the
    // route that works for handle-built PVs.
    // severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID
    link.set_alarm(3, 17, "device unreachable").await?;
}

Python

Same two halves. alarm_limits=(lolo, low, high, hihi) is a keyword argument rather than a builder call:

pressure = spvirit.ai(
    "SIM:PRESSURE",
    50.0,
    units="bar",
    desc="Vessel pressure",
    # lolo, low, high, hihi — published to clients, not evaluated.
    alarm_limits=(5.0, 10.0, 90.0, 110.0),
)

link = spvirit.ai("SIM:LINK", 0.0, desc="Device link health")

server = spvirit.Server(pvs=[pressure, link], compute_alarms=True)
server.start()
# severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID
# status is an EPICS status code; the message is free text.
link.set_alarm(3, 17, "device unreachable")

# Deciding severity yourself, from the value. This is the route that works
# for handle-built PVs, since the limits above are never compared.
for reading in (50.0, 95.0, 120.0):
    pressure.set(reading)
    if reading >= 110.0:
        pressure.set_alarm(2, 4, "HIHI")
    elif reading >= 90.0:
        pressure.set_alarm(1, 4, "HIGH")
    else:
        pressure.set_alarm(0, 0, "")
    print(f"SIM:PRESSURE = {reading}")
    time.sleep(1)

What to notice

.alarm_limits() is published but not evaluated. This is the trap in this chapter. Calling .alarm_limits(lolo, low, high, hihi) on a Pv handle — or passing alarm_limits=(...) in Python — fills in the valueAlarm structure, and clients can read those limits back with spinfo. But the value is never compared against them, even with .compute_alarms(true):

$ spget SIM:PRESSURE
SIM:PRESSURE  50

$ spput SIM:PRESSURE 95 && spget SIM:PRESSURE
SIM:PRESSURE  95          # no MINOR, despite high = 90

The cause is two parallel sets of fields. Pv::alarm_limits writes nt.value_alarm_* (spvirit-server/src/pv.rs:329), while the evaluator update_alarm_from_value reads nt.alarm_low/alarm_high/alarm_lolo/ alarm_hihi (spvirit-types/src/lib.rs:285) — and only the .db loader populates those (spvirit-server/src/db.rs:360).

So today: computed alarms require a .db file. For handle-built PVs, call set_alarm yourself, as the examples above do.

INVALID is for "I don't know", not "bad value". Severity 3 means the reading cannot be trusted — the device is unreachable, the scan threw, the link is down. A value that is simply too high is MAJOR, not INVALID. This matters because clients treat INVALID as "ignore this number".

A failing calc posts 0.0 with severity NONE. It looks like a genuine reading of zero. If a computed PV must distinguish broken from zero, set INVALID explicitly — see Simulating a device.

set_alarm bypasses the deadband and does not evaluate links. It is a direct write to the alarm block.

Run it

# Terminal 1
cargo run -p spvirit-server --example alarms
# or: python spvirit-py/examples/demo_alarms.py

# Terminal 2
spget SIM:LINK          # INVALID device unreachable
spmonitor SIM:PRESSURE
$ spget SIM:LINK
SIM:LINK 2026-08-06 09:14:45.114   0 INVALID SIMM device unreachable

$ spget SIM:PRESSURE
SIM:PRESSURE 2026-08-06 09:14:45.114  50

spget appends severity, status and message after the value when the alarm is not NO_ALARM — and prints nothing extra when it is, which is why SIM:PRESSURE at 50 looks like a plain reading.

The Rust alarms example is static, so spmonitor SIM:PRESSURE shows one line and then waits. Use the Python example to watch the limits fire:

python spvirit-py/examples/demo_alarms.py   # terminal 1
spmonitor SIM:PRESSURE                      # terminal 2
SIM:PRESSURE 2026-08-06 09:26:17.437  50
SIM:PRESSURE 2026-08-06 09:26:18.438  95
SIM:PRESSURE {alarm={severity=1, status=4, message="HIGH"}} MINOR HIGH
SIM:PRESSURE 2026-08-06 09:26:19.439 120
SIM:PRESSURE {alarm={severity=2, status=4, message="HIHI"}} MAJOR HIGH HIHI

Two things worth pausing on. The value and the alarm arrive as separate updates — the server changed value first, then the record layer evaluated the limits and changed alarm. And the second line of each pair shows only the field that changed, because a monitor sends a change bitset; spmonitor renders the changed subtree and then the decoded severity.

Next

Serving a .db file.

Serving a .db file

Verified · db_file.rs · example.db · demo_db_file.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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

FieldEffect
VALinitial value
DESCdescription, served in display.description
EGUengineering units
PRECdisplay precision
LOPR / HOPRdisplay limits (a GUI hint; nothing enforces them)
LOW / HIGHMINOR alarm limits — evaluated, given .compute_alarms(true)
LOLO / HIHIMAJOR alarm limits — same
MDELmonitor deadband
ADELarchive deadband
DRVL / DRVHdrive limits — advisory only, spvirit does not clamp
SCAN"1 second" etc., for periodic reprocessing
INPinput link, for scanned records that copy another PV
FTVL / NELMelement type and count, for array records
ZNAM / ONAMthe two state names of a bi/bo
INDX / MALMwindow 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.

Tables and images

Verified · exotic_nt.rs · demo_table.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What you'll build

An NtTable — columnar data, like a scan result or a device inventory — and an NtNdArray — a framed image.

These are the two places where spvirit steps outside the EPICS record model. As Records vs raw NT explains, there is no table record in EPICS Base. An NtTable is a payload, not a processing entity: nothing scans it, nothing computes alarms from it, and there is no .db syntax that produces one. You are using PVAccess as a transport for structured data.

NtTable

A table is a set of equal-length named columns plus display labels:

labels:  string[]         # what to show in a header row
value:
  x: double[]             # one array per column
  y: double[]

Rust

#![allow(unused)]
fn main() {
            let table_nt = NtTable {
                labels: vec!["X".to_string(), "Y".to_string()],
                columns: vec![
                    NtTableColumn {
                        name: "x".to_string(),
                        values: ScalarArrayValue::F64(xs),
                    },
                    NtTableColumn {
                        name: "y".to_string(),
                        values: ScalarArrayValue::F64(ys),
                    },
                ],
                descriptor: Some("SIM table demo".to_string()),
                alarm: None,
                time_stamp: None,
            };
            store.put_nt("SIM:TBL", NtPayload::Table(table_nt)).await;
}

Python

# An NTTable is a dict of equal-length columns.
# Labels default to the column names.
builder.nt_table("SIM:TBL", {"x": [0.0] * 8, "y": [0.0] * 8})

Python's builder takes a plain {name: list} dict and infers each column's wire type; types= overrides that per column. labels defaults to the column names.

NtNdArray

An image is flat data plus a dimension list. The dimensions carry offset, binning and reversal so a client can reconstruct a region of interest without a second PV.

Rust

#![allow(unused)]
fn main() {
            let ndarray_nt = NtNdArray {
                value: ScalarArrayValue::U8(pixels),
                codec: NdCodec {
                    name: "none".to_string(),
                    parameters: HashMap::new(),
                },
                compressed_size: 16,
                uncompressed_size: 16,
                dimension: vec![
                    NdDimension {
                        size: 4,
                        offset: 0,
                        full_size: 4,
                        binning: 1,
                        reverse: false,
                    },
                    NdDimension {
                        size: 4,
                        offset: 0,
                        full_size: 4,
                        binning: 1,
                        reverse: false,
                    },
                ],
                unique_id: tick as i32,
                data_time_stamp: Default::default(),
                attribute: vec![],
                descriptor: Some("SIM 4x4 image".to_string()),
                alarm: None,
                time_stamp: None,
                display: None,
            };
            store
                .put_nt("SIM:IMG", NtPayload::NdArray(ndarray_nt))
                .await;
}

Python

The Python builder is the compact form of the same thing:

# An NTNDArray is flat data plus dimensions. On the builder each dimension
# is a (size, full_size) pair: the served extent and the extent of the
# underlying frame.
builder.nt_ndarray("SIM:IMG", [0] * 16, [(4, 4), (4, 4)], type="ubyte")

Driving both

Neither type accepts a client PUT, so the server updates them with store.put_nt(...) — the raw-NT level from Records vs raw NT:

# Neither type is writable over the wire, so the server drives both with
# put_nt. The payload constructors take dimensions as a flat list of sizes,
# where the builder takes (size, full_size) pairs.
for tick in range(20):
    xs = [float(i) for i in range(8)]
    ys = [math.sin(v * 0.7 + tick * 0.15) for v in xs]
    store.put_nt("SIM:TBL", spvirit.NtTable({"x": xs, "y": ys}, labels=["X", "Y"]))

    frame = [(i * 16 + tick * 8) % 256 for i in range(16)]
    store.put_nt("SIM:IMG", spvirit.NtNdArray(frame, [4, 4], type="ubyte"))
    time.sleep(0.5)

Watch the dimension argument, which is the one place the two APIs disagree: the builder takes (size, full_size) tuples, while the NtNdArray constructor takes a flat list of sizes and sets full_size equal to each size. Offset, binning and reversal are not reachable from the Python constructor at all; build the payload in Rust if you need a region of interest.

What to notice

Both types accept a wire PUT. RecordInstance::apply_put (spvirit-server/src/apply.rs:609) dispatches NtTable and NtNdArray PUTs to apply_table_put/apply_ndarray_put, so a client write updates the record and restamps it like any other. The examples above still use store.put_nt(...) because that is the natural way to drive a server-to-client payload from Rust or Python code, not because a client PUT is refused.

No deadband, no alarm computation, no scanning. All of that lives in the record layer, which these types are not part of. Every put_nt posts to every subscriber.

spget prints them structurally.

$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, ...], y=[0.000000, 0.644218, ...]}

$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, ...]}

The ubyteValue field name is not decoration — NTNDArray's value is a union, and the field name identifies which arm is populated. An int16 image would come back as shortValue.

There is no table viewer in the toolbox. spget prints the shape, and that is the extent of it. sptable is a server, not a client — an interactive spreadsheet IOC that serves an NtTable — so it is the right tool for producing test data, not for inspecting someone else's PV.

Element types are fixed at creation. Writing an f64 column into a table created with int columns coerces to the record's type rather than retyping the record. The record is the authority.

Python needs lists, not numpy arrays. Call .tolist()bytes is also accepted for ubyte data.

Run it

# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_table.py

# Terminal 2
spget SIM:TBL
spget SIM:IMG
$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, 2.000000, 3.000000,
4.000000, 5.000000, 6.000000, 7.000000], y=[0.000000, 0.644218, 0.985450,
0.863209, 0.334988, -0.350783, -0.871576, -0.982453]}

$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, 48, 64, 80, 96, 112,
128, 144, 160, 176, 192, 208, 224, 240]}

Both are one long line, wrapped here. y is sin(x) and the image is a 4×4 greyscale ramp — small enough to read, which is the whole point of the example. Note what spget does not print: the table's labels (["X", "Y"]), and the image's dimension array. Both are on the wire; spget renders the value field only.

spinfo SIM:IMG shows why NTNDArray is the most involved normative type in the book — value is a union of twelve typed arrays, and the shape lives in a separate dimension structure array:

$ spinfo SIM:IMG
SIM:IMG:
struct epics:nt/NTNDArray:1.0
value: union
  booleanValue: array
  byteValue: array
  shortValue: array
  intValue: int[]
  ...
  stringValue: string[]
codec: structure
  name: string
  parameters: any
compressedSize: long
uncompressedSize: long
dimension: structure[]
  size: int
  offset: int
  fullSize: int
  binning: int
  reverse: boolean
uniqueId: int
dataTimeStamp: structure
  ...
attribute: structure[]
  name: string
  value: any
  descriptor: string
  sourceType: int
  source: string
descriptor: string
alarm: structure
  ...

Only the arm you filled in is transmitted, so a ubyte image does not pay for the eleven other array types.

Next

Custom data sources.

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 · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

What you'll build

A PV provider that is not a record store at all — PVs backed by a file, by another process, by a naming convention, or computed on demand.

Everything so far has used the built-in record store: you declare PVs up front and the server holds their values. A source replaces that with your own code. The server asks "do you own this name?" and, if you say yes, routes every GET, PUT and subscribe to you.

The Source trait

#![allow(unused)]
fn main() {
fn claim(&self, name: &str)   -> Option<PvInfo>;   // do I own this name?
fn get(&self, name: &str)     -> Option<NtPayload>;
fn put(&self, name, value)    -> Result<Vec<(String, NtPayload)>, String>;
fn subscribe(&self, name)     -> Option<Receiver<NtPayload>>;
fn rpc(&self, name, args)     -> Result<NtPayload, String>;   // has a default
fn names(&self)               -> Vec<String>;
}

(Shown with the Pin<Box<dyn Future<...>>> wrappers elided — the trait is object-safe rather than async fn, so every method returns a boxed future. See spvirit-server/src/pvstore.rs:55.)

claim is the interesting one. It runs on every channel search, and returning Some(PvInfo) commits you to serving that name.

In Python there is no trait to implement — a source is any object with the matching methods, checked by duck typing:

class MySource:
    def claim(self, name): ...        # -> PvInfo | None
    def get(self, name): ...          # -> NtScalar/NtScalarArray/... | None
    def put(self, name, value): ...   # -> payload, or raise to reject
    def rpc(self, name, args): ...    # optional
    def names(self): ...              # -> list[str]
    def on_start(self, notifier): ... # optional: stash the notifier

Two differences from Rust worth knowing up front. on_start has no Rust counterpart — it is how a Python source gets the Notifier it needs to push monitor updates. And subscribe is not part of the Python protocol: define it and it is ignored (spvirit-py/src/source.rs:535). Monitors are driven by notifier.notify(name, payload) instead.

Priority and the registry

Sources are registered with an integer order. Lower is checked first, and the built-in record store sits at 0:

#![allow(unused)]
fn main() {
    let server = PvaServer::builder()
        .ai("SIM:COUNTER", 0.0)
        // ConstSource at order -10 — checked before the built-in store
        .source("constants", -10, Arc::new(ConstSource::new()))
        // ComputedSource at order 10 — checked after the built-in store
        .source("computed", 10, Arc::new(ComputedSource))
        .build();
}
    # Lower order is asked first; the built-in record store sits at 0.
    # Fallback claims every name, so it goes last.
    server = (
        spvirit.ServerBuilder()
        .ai("BLT:X", 3.14)                        # built-in store, order 0
        .add_source("fast", 10, FastCache())
        .add_source("fallback", 100, Fallback())
        .build()
    )

So -10 shadows the built-in store, and 10 is a fallback for names it does not know. This is the whole resolution model: first claim wins.

Claiming by naming convention

A source can serve PVs that were never declared. This one owns everything starting with XYZ: and creates each PV on first touch:

#![allow(unused)]
fn main() {
    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
        let name = name.to_string();
        Box::pin(async move {
            if !self.matches(&name) {
                return None;
            }
            // Every wildcard PV is writable and uses F64
            Some(PvInfo {
                descriptor: f64_scalar_desc(),
                writable: true,
            })
        })
    }
}

The Python equivalent, claiming SCRATCH: instead — same three methods, plus the notifier that publishes each PUT to subscribers:

class WildcardSource:
    """Accept any PV under PREFIX and keep a per-name float value."""

    def __init__(self) -> None:
        self._values: dict[str, float] = {}
        self._notifier: spvirit.Notifier | None = None
        self._lock = threading.Lock()

    # Called by the server right after build() — stash the notifier.
    def on_start(self, notifier: spvirit.Notifier) -> None:
        self._notifier = notifier

    def claim(self, name: str):
        if not name.startswith(PREFIX):
            return None
        return spvirit.PvInfo.nt_scalar("double", writable=True)

    def get(self, name: str):
        if not name.startswith(PREFIX):
            return None
        with self._lock:
            val = self._values.setdefault(name, 0.0)
        return spvirit.NtScalar(val)

    def put(self, name: str, value):
        """value is a Python dict/value built from the PUT payload."""
        if not name.startswith(PREFIX):
            return None
        new_val = _coerce_float(value)
        with self._lock:
            self._values[name] = new_val
        # Publish the update to PVA monitor subscribers.
        if self._notifier is not None:
            self._notifier.notify(name, spvirit.NtScalar(new_val))
        # Return propagation list (only this PV changed).
        return spvirit.NtScalar(new_val)

    def names(self):
        # Report only the names we've seen so far. A dynamic namespace
        # cannot enumerate what does not exist yet; the PVs still serve.
        with self._lock:
            return list(self._values.keys())
$ spget XYZ:NEW
XYZ:NEW   0          # sprang into existence on the search

$ spput XYZ:NEW 42 && spget XYZ:NEW
XYZ:NEW  42

$ spget ABC:NEW
Error: Timeout("search response")    # nothing claims ABC:

Note the failure mode for an unclaimed name: a search timeout, not a "not found" error. Nothing answers the UDP search, so the client waits.

Backing PVs with a file

#![allow(unused)]
fn main() {
impl Source for JsonSource {
    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
        let name = name.to_string();
        Box::pin(async move {
            if self.pvs.read().await.contains_key(&name) {
                Some(PvInfo {
                    descriptor: f64_desc(),
                    writable: true,
                })
            } else {
                None
            }
        })
    }

    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
        let name = name.to_string();
        Box::pin(async move {
            let pvs = self.pvs.read().await;
            let val = *pvs.get(&name)?;
            Some(NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(
                val,
            ))))
        })
    }

    fn put(
        &self,
        name: &str,
        value: &DecodedValue,
    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
        let name = name.to_string();
        let value = value.clone();
        Box::pin(async move {
            let new_val = match &value {
                DecodedValue::Float64(v) => *v,
                DecodedValue::Int32(v) => *v as f64,
                DecodedValue::Structure(fields) => fields
                    .iter()
                    .find(|(k, _)| k == "value")
                    .and_then(|(_, v)| match v {
                        DecodedValue::Float64(f) => Some(*f),
                        DecodedValue::Int32(i) => Some(*i as f64),
                        _ => None,
                    })
                    .ok_or("missing numeric 'value' field")?,
                _ => return Err("unsupported value type".to_string()),
            };

            {
                let mut pvs = self.pvs.write().await;
                if !pvs.contains_key(&name) {
                    return Err(format!("PV '{}' not found in JSON store", name));
                }
                pvs.insert(name.clone(), new_val);
            }

            // Persist to disk after every write
            self.persist().await;
            println!("[json_source] persisted {} = {}", name, new_val);

            let payload = NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(new_val)));
            Ok(vec![(name, payload)])
        })
    }
}

Values survive a restart because put writes the JSON file synchronously and the constructor reads it back:

$ spput JSON:SETPOINT_A 123.4
JSON:SETPOINT_A OK
# ... stop the server, start it again ...
$ spget JSON:SETPOINT_A
JSON:SETPOINT_A 123.4

Pushing monitor updates

A source with a value that changes on its own — a polled instrument, a subscription to some other system — needs to push. In Rust that is subscribe, returning a channel the server drains. In Python subscribe is ignored; you keep the Notifier from on_start and call it from whatever thread produces the data:

    # A Python source's `subscribe` is ignored: monitor events come from the
    # notifier the server hands to `on_start`. Nothing polls `get` on your
    # behalf, so a source that never notifies looks frozen to a subscriber.
    def _loop(self):
        t = 0.0
        while not self._stop.is_set():
            new = {
                "SENSOR:TEMP":     22.0 + 2.0 * math.sin(t),
                "SENSOR:PRESSURE": 1.0 + 0.1 * math.sin(t * 1.7),
                "SENSOR:FLOW":     5.0 + 1.0 * math.sin(t * 0.4),
            }
            with self._lock:
                self._values.update(new)
            # Publish updates — this is what delivers monitor events to clients.
            if self._notifier is not None:
                for name, v in new.items():
                    self._notifier.notify(name, spvirit.NtScalar(v))
            t += 0.2
            time.sleep(0.5)

RPC

rpc is the one trait method with a default implementation — it returns Err("RPC not supported"), so a source only opts in by overriding it:

#![allow(unused)]
fn main() {
    fn rpc(
        &self,
        name: &str,
        args: &DecodedValue,
    ) -> Pin<Box<dyn Future<Output = Result<NtPayload, String>> + Send + '_>> {
        let name = name.to_string();
        let args = args.clone();
        Box::pin(async move {
            if name != "RPC:add" {
                return Err(format!("unknown RPC channel '{}'", name));
            }

            let a = extract_f64(&args, "a").unwrap_or(0.0);
            let b = extract_f64(&args, "b").unwrap_or(0.0);
            let sum = a + b;
            println!("[rpc] {a} + {b} = {sum}");

            Ok(NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(
                sum,
            ))))
        })
    }
}

In Python it is likewise optional — a source without an rpc method simply has no RPC channel:

    # `rpc` is optional: a source that does not define it simply has no RPC.
    def rpc(self, name: str, args):
        if name != self.CHANNEL:
            raise RuntimeError(f"unknown RPC channel: {name}")
        # `args` is a Python dict built from the decoded request structure.
        a = _as_float(args.get("a", 0.0))
        b = _as_float(args.get("b", 0.0))
        return spvirit.NtScalar(a + b)

spvirit ships no general-purpose RPC client. Neither spvirit-client nor any of the CLI tools can call an arbitrary RPC channel — the only RPC in the client is an internal path used by pvlist. To exercise an RPC source, use p4p or pvxs:

from p4p.client.thread import Context
ctx = Context('pva')
print(ctx.rpc('RPC:add', {'a': 3.0, 'b': 4.0}))   # 7.0

Other shapes in the repo

ExamplePattern
passthrough_source.rsdecorator — wraps another source to add logging, access control, rate limiting
aggregate_source.rsderived PVs computed from the built-in store's values
custom_pvstore.rsreplacing the store wholesale rather than layering on it
mailbox.rsminimal writable scratch PVs

The Python family is demo_source_*.pysensor, 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.

A complete IOC

Verified · complete_ioc.rs · demo_complete_ioc.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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:

PVRecordRole
VAC:PRESSUREaiscanned readback, pumping down
VAC:SETPOINTaotarget pressure, range-checked on write
VAC:ERRORcalcreadback minus setpoint
VAC:RGAaaia 64-point residual-gas spectrum
VAC:LINKaicontroller 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

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

spvirit-tools ships twelve binaries. They are named sp* rather than pv* so they can sit alongside an EPICS Base installation without shadowing pvget, pvput, and friends.

ToolKindWhat it does
spgetclientread a PV once
spputclientwrite a PV
spmonitorclientsubscribe to changes
spinfoclientprint a PV's type structure
splistclientdiscover servers and their PVs
spexploreclient, TUIbrowse a server interactively
spsearchclient, TUIpassively watch PVA search traffic
spsineclientdrive a PV with a sine wave
spget_compareofflinereplay a captured GET frame
spserverserverserve a .db file
sptableserver, TUIan interactive spreadsheet IOC
spdodecaserverserve a rotating wireframe as an image

Building them

The binaries are gated behind Cargo features, so a plain cargo build -p spvirit-tools produces none of them:

cargo build -p spvirit-tools --all-features
FeatureTools it enables
clientspget, spput, spmonitor, spinfo, splist, spsine, spget_compare
serverspserver, spdodeca
client + tuispexplore, spsearch
server + tuisptable

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:

FlagMeaning
-w, --timeouttimeout in seconds
--servertalk to ip:port directly, skipping search
--search-addrsearch target IP; defaults to EPICS_PVA_ADDR_LIST or broadcast
--bind-addrlocal IP to bind the search socket to
--name-serverPVA name server host:port; repeatable via EPICS_PVA_NAME_SERVERS
--udp-portsearch port (default 5076)
--tcp-portdefault server port (default 5075)
--no-broadcastdisable UDP broadcast/multicast search; same as EPICS_PVA_AUTO_ADDR_LIST=NO
--authnz-useroverride the AuthNZ user sent at connect
--authnz-hostoverride the AuthNZ host sent at connect
-F, --fieldscomma-separated dotted field paths to request; empty means all
-d, --debugverbose 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 · docs-verify

The badge reports the whole docs-verify suite, 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagMeaning
--json JSONJSON payload to write
--simple-flowinit + write only; skip the pre/post GET, the get-put probe, and DESTROY_REQUEST
--no-flow-fallbackdo not retry with the simple flow when the full flow fails

By default spput performs the full EPICS-Base-style PUT flow: a GET before the write, a get-put capability probe, the write, a GET after, then an explicit destroy. That is what a real pvput does, and it is what exercises the same code paths in a server under test.

Output

$ spput VAC:SETPOINT 5e-4
VAC:SETPOINT OK

$ spput VAC:SETPOINT 1.0
VAC:SETPOINT ERROR protocol error: PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3
Error: Protocol("PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3")

The exit status is non-zero on failure, so spput ... && spget ... is safe in a script.

Gotchas

A rejected write reaches the server twice. When the full flow fails, spput silently falls back to the simple flow (spvirit-tools/src/bin/spvirit_put.rs:214). A server-side on_put callback therefore fires once for an accepted write and twice for a rejected one. Pass --no-flow-fallback to suppress the retry, and write on_put callbacks to be idempotent either way — see Reacting to writes.

Read-only records refuse explicitly. ai, bi, stringin and aai answer with PUT init error: Write access denied rather than accepting and discarding.

Enum writes are accepted and dropped. spput SIM:MODE --json '{"value":{"index":2}}' prints OK and changes nothing. This is a server bug, documented in Enums.

spmonitor

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, 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

FlagMeaning
--rawprint the raw hex payload
--jsonprint JSON instead of the default line format
--pipeline Nenable 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagMeaning
-f, --field PATHinspect a sub-field, e.g. value or alarm.severity
-t, --terseone-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 · docs-verify

The badge reports the whole docs-verify suite, 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagMeaning
--poll-interval SECShow often to refresh the selected PV

Workflow

  1. Press r to discover servers.
  2. Select a server in the left pane and press Enter.
  3. Select a PV in the middle pane and press Enter.
  4. Watch streaming value and structure updates on the right.

Keys

KeyAction
qquit
htoggle the help modal
Tabcycle focus between the three panes
/ navigate the focused pane
Enteractivate the selection
ftype a PV filter (Enter applies)
aadd a PV by name (Enter applies)
ttoggle between the text and chart views
rrefresh discovery, list, or monitor
ppause / resume the monitor
x or Esccancel 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 · docs-verify

The badge reports the whole docs-verify suite, 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:

FlagDefaultMeaning
-p, --udp-port PORT5076UDP search port to listen on
-b, --bind-addr IPlocal IP for the listener
-d, --debugoffverbose logging

Keys

KeyAction
qquit
htoggle help
Tabcycle focus between the table and the detail panel
/ , PgUp / PgDnnavigate
/filter PV names (Enter applies, Esc cancels)
scycle sort mode
ppause / resume updates
cclear 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagDefaultMeaning
--freq HZsine frequency
--rate Nwrites per second
--amp Aamplitude
--offset Overtical offset
--phase RADphase in radians
--duration SECS0run 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagMeaning
--dump-file PATHhex dump, human-readable
--dump-raw PATHbinary 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 · docs-verify

The badge reports the whole docs-verify suite, 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

FlagDefaultMeaning
--db-file PATHEPICS .db file to load
--listen-addr ADDR0.0.0.0address to bind
--tcp-port PORT5075PVA TCP port
--udp-port PORT5076PVA search port
--reload-interval SECS2how often to re-read the .db file
--advertise-addr ADDRaddress to put in search responses
--beacon-period SECSbeacon interval
--beacon-addr IP:PORTbeacon target
--conn-timeout SECSidle connection timeout
--compute-alarmsoffderive severity from LOW/HIGH/LOLO/HIHI
--pvlist-mode MODElistoff, discover, or list
--pvlist-max N1024cap on names returned by a listing
--pvlist-allow-pattern REregex filter on names exposed by a listing
--debugoffverbose 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:

ModeSearchsplist <target>
list (default)answeredfull list
discoveransweredrefused, "RPC list endpoint disabled"
offansweredrefused

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 · docs-verify

The badge reports the whole docs-verify suite, 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.

FlagDefaultMeaning
--port PORT5075TCP port
--udp-port PORT5076UDP search port
--rate HZ10animation tick rate

Two ways to drive it

Single keys operate on the selected row; : opens a command line for everything else.

KeyAction
aadd a PV through a guided prompt
e or Enteredit the selected row's value
ddelete the selected row
j / k or / move the selection
:open the command line
?scrollable help modal
q or Escquit

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.

GeneratorParameters and defaults
sineamp=1 offset=0 period=10 phase=0
rampmin=0 max=1 period=10
trianglemin=0 max=1 period=10
squarelo=0 hi=1 period=10 duty=0.5
noisemin=0 max=1
walkstart=0 step=1 min=0 max=1
countstart=0 step=1
cycleperiod=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 · docs-verify

The badge reports the whole docs-verify suite, 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.

FlagDefaultMeaning
--pv NAMEDODECA:IMAGEPV name to serve
--width N256image width in pixels
--height N256image height in pixels
--rate HZ10frame update rate
--tcp-port PORT5075TCP server port
--udp-port PORT5076UDP search port
--listen-addr ADDR0.0.0.0listen address
--conn-timeout SECS60idle connection timeout
--debugoffverbose logging

Running it

spdodeca --width 512 --height 512 --rate 25

Then point any NTNDArray-capable viewer at DODECA:IMAGE. spget will confirm it is there, though it prints the pixels rather than the picture:

$ spget DODECA:IMAGE
DODECA:IMAGE 2026-08-04 10:46:46.086 {ubyteValue=[0, 0, 0, 0, 0, ...]}

ubyteValue names the populated arm of NTNDArray's union-typed value field — the frame is 8-bit greyscale. spinfo DODECA:IMAGE prints the whole union and the dimension list alongside it.

What it is for

Testing an image client. Area-detector viewers are hard to develop against without a detector. This gives you a deterministic, always-running one.

Load-testing the wire. A 512×512 frame at 25 Hz is about 6 MB/s of PVAccess traffic through a single monitor — enough to expose buffering problems in a client.

Checking the NTNDArray encoding. It exercises the union-typed value field and the dimension list, which is the part of the type most likely to be implemented differently at the other end. See Tables and images.

Crate map

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

Spvirit is seven crates in one workspace. They are published separately, so you depend on the layer you need and nothing above it. Six of them form the layered stack below; the seventh, spvirit-calc, stands apart.

The layering

graph TD
    types[spvirit-types]
    codec[spvirit-codec]
    client[spvirit-client]
    server[spvirit-server]
    tools[spvirit-tools]
    py[spvirit-py]

    codec --> types
    client --> codec
    client --> types
    server --> codec
    server --> types
    tools --> client
    tools --> server
    tools --> codec
    tools --> types
    py --> client
    py --> server
    py --> codec
    py --> types

The direction is strict and there are no cycles. spvirit-client and spvirit-server do not depend on each other — the only thing they share is the codec and the type vocabulary underneath it.

What each one is for

CrateDepends onYou want it whenAPI docs
spvirit-typesYou need the Normative Type structs (NtScalar, NtTable, NtNdArray, NtEnum, ScalarValue) without any I/O. Pure data and validation.docs.rs
spvirit-codecspvirit-typesYou are encoding or decoding PVAccess frames yourself — a proxy, an analyser, a test harness.docs.rs
spvirit-clientspvirit-codecYou are reading, writing, or monitoring PVs from Rust.docs.rs
spvirit-serverspvirit-codecYou are serving PVs: a soft IOC, a simulator, a gateway.docs.rs
spvirit-toolsclient + serverYou want the sp* command-line programs. Also usable as a library, but it exists mainly to ship binaries.docs.rs
spvirit-pyclient + serverThe spvirit Python module. A PyO3 extension, not a pure-Python package.Python API

A seventh crate, spvirit-calc (docs.rs), implements the EPICS CALC expression language. It is a workspace member and is published alongside the rest, but nothing in the diagram above depends on it — it stands alone, and you add it explicitly if you want it.

Incomplete. spvirit-calc is a work in progress. Its conformance corpus (spvirit-calc/tests/base_corpus.rs, transcribed from EPICS Base's epicsCalcTest.cpp) still has failing cases in the parser's conditional/: error classification, so that corpus test is currently commented out. The per-module unit tests pass, but treat the crate as unfinished. See Known gaps.

This book is the tutorial; the docs.rs pages are the exhaustive per-item reference. They are generated from the same source tree and versioned with each release, so latest always matches the newest published version.

Versions

All are released together from the workspace, so their version numbers move in step. spvirit-py is published to PyPI rather than crates.io and carries its own version.

Feature flags

Only spvirit-tools is feature-gated:

FeatureDefaultGates
clientyesspget, spput, spmonitor, spinfo, splist, spsine, spget_compare
serveryesspserver, spdodeca
tuiyesspexplore, spsearch; also required (with server) by sptable

All three are on by default. A build with --no-default-features produces no binaries at all — a fact worth knowing before you spend ten minutes wondering where they went. See Installation.

spvirit-types, spvirit-codec, spvirit-client and spvirit-server declare no features.

Where to read further

The Developer guide walks each crate's internals with file-and-line citations — Types and Codec, Server, Client and Tools, Python Bindings.

Record types

Verified · no code on this page · docs-verify

The badge reports the whole docs-verify suite, 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

RecordEPICS meaningHandle APIBuilder.dbWritable
aianalog inPv::ai.ai()record(ai, …)no¹
aoanalog outPv::ao.ao()record(ao, …)yes
bibinary inPv::bi.bi()record(bi, …)no
bobinary outPv::bo.bo()record(bo, …)yes
longin32-bit integer inPv::longinno
longout32-bit integer outPv::longoutyes
stringinstring inPv::string_in.string_in()record(stringin, …)no
stringoutstring outPv::string_out.string_out()record(stringout, …)yes
mbbimulti-bit binary inPv::mbbi.mbbi()parses, then refused²yes³
mbbomulti-bit binary outPv::mbbo.mbbo()parses, then refused²yes³
waveformarrayPvArray::waveform.waveform()record(waveform, …)yes
aaiarray analog inPvArray::aai.aai()record(aai, …)no
aaoarray analog outPvArray::aao.aao()record(aao, …)yes
subArraywindow onto an array.sub_array()record(subarray, …)no
NtTabletable payload.nt_table()refused²yes⁴
NtNdArrayimage / detector frame.nt_ndarray()refused²yes⁴
Genericarbitrary structure.generic()refused²yes

¹ An ai becomes writable when SIMM is set — the simulation-mode path (spvirit-server/src/types.rs:308).

² RecordType::from_db_name maps twelve .db spellings, including mbbi (also spelled ntenum) and mbbo. Those two then reach an arm in spvirit-server/src/db.rs:550 that prints "is not a standard EPICS Base record type and cannot be loaded from .db files" and drops the record. mbbi and mbbo are standard EPICS Base record types; the message is wrong. See Known gaps.

³ Writable in the sense that the record accepts write access. A wire PUT of an enum index is currently dropped — see Known gaps.

⁴ Writable via a client PUT as well as store.put_nt(). The NtTable/ NtNdArray arms of RecordInstance::apply_put (spvirit-server/src/apply.rs:609) apply the wire fields and restamp the record.

Reading the columns

Handle APIPv<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.

BuilderPvaServer::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 · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

Every progressive example in Part III shows Rust and Python side by side, so the tutorial route is the one to take first. This page is the orientation map: what the module contains, and where the full reference lives.

The complete API reference is spvirit-py/README.md — around a thousand lines covering every class, method and keyword argument. It is not duplicated here; this page tells you which part of it you want. Every object also carries a docstring, so help(spvirit.ai) and help(spvirit.Server) work at the interpreter prompt.

For the Rust side there is generated reference documentation on docs.rs: types · codec · client · server · tools · calc. The Python module is a thin layer over spvirit-client and spvirit-server, so when a Python docstring is terse the Rust page for the same call is often the fuller answer. See the crate map.

Install

pip install spvirit

A compiled PyO3 extension, not a pure-Python package. Wheels ship for the usual platforms; building from source needs a Rust toolchain. See Installation.

The four layers

LayerImportWhat it gives you
Typed PV handlesspvirit.ai, spvirit.ao, …IOC-style records you keep a handle to. The one to start with.
Server + storespvirit.ServerRuns the PVAccess server; store reaches records by name.
Clientspvirit.get, spvirit.put, spvirit.monitor, spvirit.ChannelReading and writing other people's PVs.
Low levelspvirit.lowlevel, spvirit.codecRaw frames and wire encoding, for proxies and analysers.

Constructors

The module-level PV constructors mirror the Rust handle API one for one:

ai  ao  bi  bo  string_in  string_out  longin  longout
mbbi  mbbo  waveform  aai  aao  calc  pv  scalar

pv and scalar are the generic forms — you pass the type explicitly rather than getting it from the constructor name. The full type-coverage table (which NT scalar types each constructor accepts) is in the README's NT scalar type coverage section.

Sync and async

Most operations come in both flavours: set/set_async, get/get_async, connect/connect_async and so on. The sync forms release the GIL while they block, so they are safe to call from a thread. The README's Threading and async model section is the one to read before you mix them.

The rule that catches everyone

Attach callbacks before starting the server. on_put, scan and calc must be registered on a handle while it is still unbound. Once Server.start() has run, the handle is bound and a late on_put will not fire. The same rule holds in Rust, but Python makes it easier to trip over because the server object is mutable and the failure is silent. See Reacting to writes and Troubleshooting.

Examples

spvirit-py/examples/ holds around thirty runnable scripts — one concept each. The ones the book's chapters use directly:

ScriptChapter
demo_first_pv.pyYour first PV
demo_scalars.pyServing scalars
demo_get.py, demo_put.pyReading and writing
demo_monitor.pyMonitoring changes
demo_on_put.pyReacting to writes
demo_scan.py, demo_calc.pySimulating a device
demo_waveform.pyArrays and waveforms
demo_enums.pyEnums and binary records
demo_alarms.pyAlarms and severity
demo_table.pyTables 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 · docs-verify

The badge reports the whole docs-verify suite, 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:

  1. Is the server up? splist with no arguments broadcasts and lists every server that answers.
  2. Same subnet? PVA searches go out on UDP 5076 by broadcast. Across a router, set EPICS_PVA_ADDR_LIST to the server's address, or pass --server host:port to skip the search entirely.
  3. Firewall? Windows blocks inbound UDP on a new binary the first time it binds. The server starts fine and answers nothing.
  4. Multi-homed host? The search leaves on one interface. spsearch shows 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 · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

Divergences between what spvirit does and what an EPICS user would expect, found while writing this book and confirmed by running the code. Each one is documented where it bites, in the relevant chapter; this page collects them so you can scan the list before you spend an afternoon on one.

Nothing here is a plan. These are findings, not commitments.

1. An enum write is accepted and dropped

What happens. spput SIM:MODE --json '{"value":{"index":2}}' prints OK. The record does not change.

Why. The NtEnum arm of RecordInstance::apply_put (spvirit-server/src/apply.rs:611) accepts a field literally named value carrying a scalar integer. A wire PUT of an enum delivers value as a sub-structure, so no branch matches, changed stays false, and the operation reports success.

Consequence. The one failure mode worse than an error: a write that says it worked. mbbi/mbbo records are read-only in practice over the wire.

Where it is documented. Enums and binary records, spput.

2. .db refuses record types that EPICS Base has

What happens. Given this file:

record(ai, "GAP:OK")        { field(VAL, "1.0") }
record(longin, "GAP:LONGIN"){ field(VAL, "7") }
record(mbbo, "GAP:MBBO")    { field(ZRST, "Off") field(ONST, "On") }
$ spserver --db-file gap_test.db
INFO spserver: Loaded DB file 'gap_test.db' with 1 PVs
Record 'GAP:MBBO': type 'mbbo' is not a standard EPICS Base record type and cannot be loaded from .db files

One of three records loaded.

Why. Two separate holes. mbbi/mbbo are recognised by RecordType::from_db_name and then rejected by an arm in spvirit-server/src/db.rs:550 whose message claims they are not standard EPICS Base record types — which they are. longin/longout are not in from_db_name at all, so they fall out through a ? (spvirit-server/src/db.rs:315) and vanish with no message whatsoever.

Consequence. A .db file written for a real IOC loses records, and the diagnostic is either misleading or absent. The silent case is the dangerous one: the count in the startup line is the only clue.

Workaround. Create those four types through the handle API (Pv::longin, Pv::mbbo, …). See Record types.

Where it is documented. Serving a .db file.

3. Alarm limits on a handle are published but never evaluated

What happens. Pv::alarm_limits(lolo, low, high, hihi) puts the limits in the payload's valueAlarm structure. Severity stays NO_ALARM however far the value goes past them.

Why. Severity computation is gated on the server-wide compute_alarms flag, which defaults to false (spvirit-server/src/server.rs:55). The handle-level limits do not turn it on.

Consequence. A PV that looks alarmed to a human reading the metadata and healthy to anything that reads severity.

Workaround. spserver --compute-alarms, or .compute_alarms(true) on the builder. That path derives MINOR from LOW/HIGH and MAJOR from LOLO/HIHI, and it works.

Where it is documented. Alarms and severity, spserver.

4. spput delivers a rejected write twice

What happens. A write a validator rejects reaches the server's on_put twice; an accepted write reaches it once.

Why. When the full EPICS-Base-style PUT flow fails, spput falls back to the simple flow without saying so (spvirit-tools/src/bin/spvirit_put.rs:214).

Consequence. Any on_put with a side effect — a log line, a counter, a hardware poke — doubles up on exactly the writes you were trying to refuse.

Workaround. --no-flow-fallback, and idempotent callbacks.

Where it is documented. spput, Reacting to writes.

5. ADEL is parsed and exposed but not applied

MDEL gates monitor posts (spvirit-server/src/simple_store.rs:545). ADEL — the archive deadband — is read out of the .db file and readable as a field, and no posting logic consults it. A .db that relies on ADEL behaves as though it were absent.

6. Wire PUT is not wired for generic structures

The Generic arm of RecordInstance::apply_put (spvirit-server/src/apply.rs:639) is a no-op — it returns false without looking at the PUT body. NtTable and NtNdArray are wired (both arms call into apply.rs's table/ndarray helpers), so a generic record is now the one kind that reports itself writable and silently discards every wire PUT — the same failure shape as gap 1. Write it server-side with store.put_nt().

7. The builder has no longin/longout

PvaServerBuilder covers fifteen record constructors and omits these two, which both the Rust handle API (Pv::longin, Pv::longout) and the Python module do have. Combined with gap 2, the builder and .db are the only two routes that cannot produce a longin. The matrix is on Record types.

8. CANCEL_REQUEST is unimplemented

The server answers PVA command CANCEL_REQUEST with "CANCEL_REQUEST command is not supported" (spvirit-server/src/handler.rs:1773). ACL_CHANGE, MESSAGE, MULTIPLE_DATA, ORIGIN_TAG and commands 14 and 16 likewise return errors. Clients that cancel a request rather than destroying the channel will see the error; the common clients do not.

9. spvirit-calc is incomplete

What happens. The spvirit-calc crate implements the EPICS CALC expression language, but it does not yet pass its own conformance corpus.

Why. The corpus (spvirit-calc/tests/base_corpus.rs), transcribed case-for-case from EPICS Base's epicsCalcTest.cpp, still has 2 of 686 cases failing. Both are error-classification mismatches in the parser's handling of conditional/: syntax: "1?" is reported as MissingOperand where Base expects a conditional error, and ":1" is reported as a conditional error where Base expects a syntax error.

Consequence. The base_corpus conformance test is currently commented out so the workspace test suite is green. The per-module unit tests in spvirit-calc/src/ all pass, but the crate should be treated as unfinished until the corpus is re-enabled and passes.

Where it is documented. Crate map.

What is not on this list

Behaviour that is deliberate and merely surprising lives in the chapters, not here — spget accepting exactly one PV name, spsine printing nothing on success, discover and off suppressing enumeration but not discovery, spget's value column being a display rendering rather than the wire value. Each is a gotcha in its own tool page.

The full engineering picture — everything above plus the internal to-do list — is in Current State and Roadmap.

Spvirit Developer Guide

Internal handover documentation for developers taking over Spvirit — a pure-Rust implementation of the EPICS PVAccess protocol (client, server, codec, CLI tools, Python bindings).

This guide covers internals: architecture, per-crate deep dives with file/line references, testing, release process, and the exact state of in-flight work. For the user-facing API story, read Part I through Part III of this site first, and spvirit-py/README.md for the full Python reference. The top-level README.md is a landing page that points here.

Chapters

#ChapterRead it when
01Architecture OverviewDay one — the crate graph, the two protocol layers, server data flow, and the invariants everything relies on
02spvirit-types & spvirit-codecBefore touching the data model or wire format
03spvirit-serverBefore touching the server: Source model, store, protocol runtime, handles, alarms/deadbands
04spvirit-client & spvirit-toolsBefore touching search/get/put/monitor or the CLI tools
05spvirit-pyBefore touching the Python bindings — especially the threading model
06Testing GuideBefore writing tests; how to run the interop suites
07Build, CI, and ReleaseBefore releasing anything; repo conventions
08Current State & RoadmapFirst, if you're picking up work — uncommitted changes, the in-flight Python value-types plan, known-gaps triage list

Day-one checklist

git clone https://github.com/ISISNeutronMuon/spvirit && cd spvirit
cargo build --release
cargo test --all                                   # should be green

# See it work — two terminals:
cargo run -p spvirit-server --example simple_server
cargo run -p spvirit-client --example pvget -- SIM:TEMPERATURE

# Python bindings:
cd spvirit-py && python -m venv .venv
.venv\Scripts\Activate.ps1                         # Windows; source .venv/bin/activate elsewhere
pip install maturin && maturin develop
python tests/test_pv_handles.py                    # expect ALL OK

Then:

  1. Read chapter 08 and run git status / git log origin/main..main — there is uncommitted work and unpushed commits at handover.
  2. 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/servertools (CLIs + integration tests) and py (PyO3). Everything on the wire is a Normative Type; the server offers an IOC-style record level (records, alarms, deadbands, auto-timestamps) as sugar over a raw-NT level (put_nt/get_nt, custom Source providers). Default ports: TCP 5075, UDP 5076. Interop is validated against EPICS Base, p4p/pvxs, and PVAccessJava.

How work is planned here

Substantial features follow a spec → plan → TDD execution workflow: a design spec, then a checkbox implementation plan (exact commands, per-task commits, Conventional Commit messages), then task-by-task execution. Those working documents are kept out of the repository — the durable record is this guide, the commit history, and the tests. One such plan — Python NT value-type selection — is mid-flight; see chapter 08 before touching spvirit-py or spvirit-server/src/pv.rs.

Where to get answers

  • Protocol questions: the pvAccess Protocol Specification and pvxs (the reference implementation this project most closely mirrors).
  • "Why is this code like this": the commit history is the archaeology — commits are per-task and carry the reasoning in their messages.
  • Wire debugging: spsearch (search traffic TUI), spget --raw / spmonitor --raw (hex dumps), spget_compare (byte-compare against captures), and the related spvirit-scry capture tool.

Architecture Overview

What this project is

Spvirit is a from-scratch Rust implementation of the EPICS PVAccess protocol: wire codec, client, server (softIOC-like), CLI tools, and Python bindings. It interoperates with EPICS Base, p4p/pvxs, and PVAccessJava.

If you are new to EPICS, read the "Key Concepts" section of the top-level README.md first — it explains PVs, records, .db files, and Normative Types (NT) with diagrams. This guide assumes that vocabulary.

Crate dependency graph

                 spvirit-types        (pure NT data model, zero deps)
                       │
                 spvirit-codec        (PVA wire format + PVD codec + state tracker)
                    ┌──┴──────────┐
             spvirit-client   spvirit-server
                    └──┬──────────┘
              ┌────────┴────────┐
        spvirit-tools      spvirit-py
        (CLI binaries)     (PyO3 bindings)
  • spvirit-typesScalarValue, 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 Source provider model, SimplePvStore record store, protocol runtime (UDP search, TCP handler, beacons, monitors), .db parser, and the typed Pv<T> handle layer. Chapter 03.
  • spvirit-tools — 11 CLI binaries (spget, spput, spmonitor, spexplore, spserver, …) plus the workspace's integration/interop test suite. Chapter 04.
  • spvirit-py — PyO3 bindings mirroring the handle API in Python, plus Python-defined dynamic sources and a low-level channel/codec surface. Chapter 05.

The two protocol layers

PVAccess is two nested encodings, and the codec keeps them in separate modules:

  1. 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, …).
  2. PVD data layer (spvd_decode.rs / spvd_encode.rs): self-describing structured data — type descriptors (FieldDesc/StructureDesc) with a per-connection introspection cache, values (DecodedValue), and bitsets for delta updates.

Key asymmetry to internalize: NT types flow into the encoder; the decoder emits DecodedValue, a separate tree. Consumers (server, client, py) each convert DecodedValue to what they need — there is no shared reverse mapping.

Server data flow (the picture to keep in your head)

                     ┌────────────── PvaServer::run ──────────────┐
 client SEARCH ──► UDP responder          TCP handler ◄── client TCP
                        │                  │       ▲
                        │            decode PUT    │ frames (writer task)
                        ▼                  ▼       │
                  SourceRegistry ──► SimplePvStore ──► MonitorRegistry
                  (priority list)    (records, MDEL,     (per-sub delta
                   builtin @0        validators, links,   frames, pipeline
                   record-fields @10 on_put, timestamps)  credits)
                   user sources)           ▲
                                           │ set_value / set (internal writes)
                              scan tasks / Pv<T> handles / links

Two write entry points converge on the store: wire PUTs (via the handler → registry → Source::put) and internal writes (scan callbacks, Pv::set, link evaluation → store.set_value). Both end at MonitorRegistry::notify_monitors, which builds full-or-delta frames per subscriber and pushes bytes into each connection's writer channel.

Two API levels, everywhere

The project consistently offers an IOC-style record level and a raw-NT level (the README's "IOC-style records vs raw NT PVs" table is the user-facing version of this):

Record levelRaw NT level
Rust serverPv<T> handles, builder methods, .db filesput_nt/get_nt, hand-built RecordInstance, custom Source
Pythonspvirit.ai(...) etc., Server(pvs=[...])Store.put_nt, NtScalar/NtTable classes, dynamic sources + Notifier
Behavioralarms computed, timestamps stamped, MDEL appliedcaller 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

  1. 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.
  2. The introspection registry is per-connection state — decoders must be reused across a connection's packets (0xFE type refs).
  3. First-claim-wins source priorityclaim must be cheap and idempotent because get/put/subscribe re-claim.
  4. on_put (post-apply, can't reject) vs PUT validator (pre-apply, can reject) are different mechanisms; the Python on_put maps to the validator.
  5. Monitor bitset ordering is heuristic (three decode variants + scoring) because implementations disagree; be careful "simplifying" it.
  6. Bool coercion trap: decoded_to_scalar_value checks truthiness before numeric types; typed paths override from_decoded to avoid it.

spvirit-types & spvirit-codec — Foundation Crates

These two crates are the foundation of the workspace: spvirit-types is the pure data model, spvirit-codec is the wire format. Everything else (-client, -server, -tools, -py) depends on both.

spvirit-types  (NtPayload, NtScalar, ScalarValue, PvValue, …)   ← zero dependencies
      │
      ▼
spvirit-codec  (PVA wire codec + PVD codec + connection state tracker)
      │             re-exports spvirit_types at crate root (lib.rs:36)
      ▼
spvirit-client / spvirit-server / spvirit-tools / spvirit-py

Deps: spvirit-types has none; spvirit-codec uses only spvirit-types, hex, tracing. Edition 2024 throughout.

spvirit-types

The entire crate is one file, spvirit-types/src/lib.rs (~617 lines): pure structs/enums for the Normative Type (NT) data model, plus builder methods and validation. No I/O, no wire format.

TypeLocationRole
ScalarValuelib.rs:9Tagged union of the twelve NTScalar value types (Bool, I8–I64, U8–U64, F32, F64, Str)
ScalarArrayValuelib.rs:25Array counterpart; len(), element_size_bytes(), type_label() at lib.rs:40–95
NtAlarm / NtTimeStamp / NtDisplay / NtControllib.rs:98–147Normative sub-structures
NtScalarlib.rs:150The big one: value + flattened alarm/display/control/valueAlarm/units + optional time_stamp (lib.rs:185)
NtScalarArraylib.rs:352Array payload
NtTable / NtTableColumnlib.rs:373–405Table; validate() checks column-length equality
NtNdArray + NdCodec/NdDimension/NtAttributelib.rs:408–504Image/detector model; validate() checks dims × element size vs uncompressed_size
NtEnumlib.rs:524index + choices; selected()
PvValuelib.rs:560Recursive value tree (Scalar/ScalarArray/Structure) so this crate can represent arbitrary structures without depending on the codec
NtPayloadlib.rs:570Top-level union: Scalar/ScalarArray/Table/NdArray/Enum/Generic{struct_id, fields} — the primary hand-off type between server/client and codec

NtScalar::update_alarm_from_value (lib.rs:285) computes alarm severity from the HIHI/HIGH/LOW/LOLO limits — this is the server's alarm engine, but note it lives here in the types crate.

Footgun: NtScalar.time_stamp is Option

None means the encoder stamps SystemTime::now() at encode time (non-deterministic — it breaks monitor delta detection of secondsPastEpoch and makes tests flaky). Some is stable. Documented at lib.rs:179–184. The server now stamps timestamps on every mutation and (in-flight change) at store-entry time precisely because of this.

spvirit-codec

File~LinesContents
lib.rs36Module decls + curated re-exports (also re-exports spvirit_types)
encode_common.rs28encode_size (PVA varint) + encode_string
epics_decode.rs2108PVA wire-format decode: header, control flags, ~20 command payload structs, PvaPacket::decode_payload dispatch, PvaOpPayload
spvd_decode.rs1507pvData (PVD) introspection + value decode: TypeCode, FieldDesc, StructureDesc, DecodedValue, PvdDecoder (incl. introspection registry, bitset decode)
spvd_encode.rs2571pvData encode / NT serialization: struct-desc encoding, per-NT-type encoders, bitset/delta/monitor encoding, PvRequest encode/decode, projection/filtering
spvirit_encode.rs1372PVA wire-format encode: encode_header, all request/response builders (search, create-channel, op init/data/status, monitor, beacon)
spvirit_state.rs1358Connection 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::newdecode_payload (epics_decode.rs:205), which dispatches on the command byte (0=Beacon, 1=ConnValidation, 3=Search, 4=SearchResp, 7=CreateChannel, 8=DestroyChannel, 9=ConnValidated, 10–14/16/20=Op, 15=DestroyRequest, 17=GetField, 18=Message, 21=CancelRequest, 22=OriginTag …). Encode entry point: encode_header (spvirit_encode.rs:66); each encode_*_response/request builds a payload then prepends the header.

Byte order is decided per-packet by header flag bit 7. Every integer read/write branches on is_be — there is no abstraction layer, the if is_be {…} else {…} pattern is repeated everywhere. Connections cache their order in ConnectionState.is_be (defaults little-endian).

Sizes/strings use the PVA varint: 1 byte < 254, 0xFE + u32 above, 0xFF = null. Warning: four near-identical size codecs exist (encode_common::encode_size, epics_decode::decode_size, spvd_encode::encode_size_pvd, spvirit_encode::encode_size_pva) — if you change one, check the others.

Introspection / type descriptors. PVD structures are described by FieldDesc/StructureDesc trees. Parse path: PvdDecoder::parse_field_desc (spvd_decode.rs:352) → parse_type_descparse_structure_desc. Tag bytes: 0x80 structure, 0x81 union, 0x82 variant (+0x08 for array forms), scalar/array mode bits & 0x18, base type & 0xE7. Encode side: spvd_encode::encode_structure_desc (spvd_encode.rs:33) plus per-NT descriptor builders (nt_scalar_desc:274, nt_scalar_array_desc:708, nt_table_desc:764, nt_ndarray_desc:933, nt_enum_desc:1191, dispatcher nt_payload_desc:1304).

The introspection registry is stateful. 0xFD = "full type with id" (parse + cache under a u16 key), 0xFE = "only id" (look up cached type). The registry lives inside a RefCell in PvdDecoder (spvd_decode.rs:296) — a single PvdDecoder instance must be reused across a connection's packets or 0xFE references won't resolve. This is easy to get wrong.

Bitsets (monitor deltas). Bit 0 = whole structure; field bits start at bit 1; nested structs consume a contiguous bit block (count_structure_fields flattens the count). Decode has three variants for the overrun-bitset ordering (decode_structure_with_bitset, _and_overrun, _then_overrun, spvd_decode.rs:853–911) because real implementations disagree on the wire ordering; PvaOpPayload::decode_with_field_desc (epics_decode.rs:1468) tries all three for MONITOR packets and scores the results (choose_best_decoded_multi / score_decoded). This is a heuristic, not spec-exact — a likely source of future bugs. Encode side: encode_nt_payload_delta (spvd_encode.rs:1916), compute_changed_bits:1812, encode_structure_bitset:464.

Op payloads. PvaOpPayload::new (epics_decode.rs:1371) handles client vs server field-offset differences, the conditional status prefix, PV-name extraction, and parses introspection on INIT responses. decoded_value is filled later once a field_desc is known.

Value decode (PvdDecoder::decode_value, spvd_decode.rs:706) is recursive over FieldType. Safety caps: scalar arrays 4 M elements, string arrays 4096, struct arrays 256, unions 128 — oversized arrays are truncated (string/struct truncation can desync the decode stream).

Segmentation is parsed but not reassembled. The flags byte carries first/middle/last segment bits and they are decoded (epics_decode.rs:83–101), but PvaPacket only decodes a single complete buffer — reassembly is the caller's job. spvirit-server/src/handler.rs and decode.rs implement their own reassembly; the codec itself does not. This is an architectural gap if you ever handle large segmented values in a new consumer.

Connection state tracker (spvirit_state.rs)

PvaStateTracker is fed protocol events (on_search, on_create_channel_request/response, on_op_init_request/response, on_op_activity, on_destroy_channel, …) and maintains CID↔SID↔PV-name maps, per-operation field_descs, a search cache, TTL-based cleanup (5 min / 40 k channels default) and snapshot/stats reporting. PV-name resolution (resolve_pv_name, spvirit_state.rs:741) is deliberately best-effort for mid-stream packet captures — the single-channel fallback is disabled when multiple ops exist to avoid mis-attribution on multiplexed connections (Phoebus). Used by the diagnostic TUI tools, not by the server/client runtimes.

Data-flow asymmetry (important)

NT types flow into the encoder (spvd_encode takes spvirit-types structs → wire bytes + StructureDesc). The decoder emits DecodedValue, a separate codec-local tree — there is no DecodedValue → NtPayload reverse mapping in these crates; each consumer interprets DecodedValue itself (see spvirit-server/src/convert.rs, spvirit-py/src/convert.rs).

Known issues & sharp edges

  • PvaHeader::new panics on < 8 bytes; use try_new for untrusted input.
  • parse_introspection_with_len has a dead if/else (spvd_decode.rs:583–591) — both branches insert the same value; harmless but confusing.
  • StructureArray null 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 every PvScalar impl works around.
  • No captured-packet fixture corpus. All tests build byte arrays inline or round-trip encode→decode. There is no regression corpus of real EPICS traffic — consider adding one (e.g. captures from pvxs/p4p/PVAccessJava) before making codec changes.
  • There are no TODO/FIXME markers in either crate; incomplete areas are only discoverable by reading. The list above is the current known set.

Tests

All inline #[cfg(test)] modules at the bottom of each file: spvd_encode 18, spvirit_encode 20, spvirit_state 15, epics_decode 9, spvd_decode 7, types 2. Round-trip oriented (encode → decode → assert equality). The state-tracker tests (spvirit_state.rs:1023–1350) double as documentation of the intended state-machine semantics. Run with cargo test -p spvirit-codec -p spvirit-types.

spvirit-server — Server Architecture

The server crate provides .db parsing, the Source provider abstraction, the PVAccess protocol runtime, and the ergonomic typed-handle (Pv<T>) layer. It is the largest and most active crate.

Module map

All paths under spvirit-server/src/.

File~LinesPurpose
handler.rs1867The core. TCP connection processor (handle_connection), UDP search responder (run_udp_search), TCP accept loop, ServerState, wildcard matching, GUID generation
simple_store.rs1493SimplePvStore: in-memory Source backed by RecordInstances — value/NT writes, subscribers, MDEL gate, link evaluation, PUT application, NTScalar descriptor builders
pv.rs1471Typed handle layer: Pv<T>, PvArray, AnyPv, PvScalar trait, pending/bound state machine, builder methods, attach
pva_server.rs1247PvaServer + PvaServerBuilder (classic API), ServeBuilder/RunningServer (handle API), shared record-construction helpers make_scalar_record/make_output_record/make_array_record
group.rs1161QSRV-style group PVs: info(Q:group) JSON parsing → GroupPvDef, and GroupSource composing members into NtPayload::Generic
types.rs1011Record model: RecordType, ScanMode, LinkExpr, DbCommonState, RecordData, RecordInstance + value-mutation methods
db.rs717EPICS .db file parser (regex, line-oriented)
apply.rs511Pure functions applying a decoded PUT to NT payloads (apply_value_update, apply_table_put, apply_ndarray_put, …)
record_fields.rs467QSRV-style field access: serves <pv>.<FIELD> and <pv>.<FIELD>$ as read-only channels; dbCommon defaults table
monitor.rs311MonitorRegistry: per-PV subscriber lists, delta/full frame building, pipeline credit accounting
convert.rs276DecodedValueScalarValue/ScalarArrayValue conversions
pvstore.rs259The Source trait + PvInfo + SourceRegistry
server.rs183Orchestration: run_pva_server_with_registry binds TCP/UDP/beacon and joins the tasks
decode.rs128PUT-body decoding with fallback strategies + segmented-message reassembly
beacon.rs67Periodic UDP beacon sender
state.rs37Per-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):

OrderLabelSourceClaims
0builtinSimplePvStoreexact record names
10record-fieldsRecordFieldSource<name>.<FIELD> refs
user.source() extraswhatever 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_nt bypass on_put/validators; each calls an _inner writer then evaluate_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 via RecordInstance::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 the on_put callback as a detached task, evaluate links.
  • Links/calc: evaluate_links (simple_store.rs:349) is a BFS over LinkDefs whose inputs include the changed PV, with a visited set for cycle detection; uses set_value_inner to avoid re-triggering.
  • Descriptor builders for NTScalar/NTScalarArray live here (simple_store.rs:645–903); Table/NdArray/Enum/Generic delegate to spvirit_codec::spvd_encode::nt_payload_desc.

Protocol runtime

run_pva_server_with_registry (server.rs:112) binds TCP first (eager EADDRINUSE so a failed start doesn't ghost-beacon), then spawns UDP-search, TCP-accept and beacon tasks and joins them.

  • UDP search (handler.rs:620): binds 5076 with SO_REUSEADDR/SO_REUSEPORT (so a co-located p4p can share the port); answers Search packets whose names the registry claims. Response IP: advertise_ip → non-unspecified listen_ip → infer_udp_response_ip (connect-a-socket trick) → zeros.
  • TCP connection (handler.rs:778): per-connection reader plus a dedicated writer task draining mpsc::channel<Vec<u8>>(128). Handshake: SET_BYTE_ORDER → CONNECTION_VALIDATION → client's validation → CONNECTION_VALIDATED. Then the command dispatch (CreateChannel; Op 10 GET / 11 PUT / 12 PUT_GET / 13 MONITOR / 20 RPC; DestroyChannel; DestroyRequest; GetField; Echo; AuthNZ silently accepted). Segmented-message reassembly at handler.rs:878–946. Idle timeout enforced per-read.
  • Beacons (beacon.rs): tick every beacon_period (default 15 s, 0 disables), reading an AtomicU16 change counter.

Data flow: external PUT → monitor update

  1. Handler decodes the PUT and calls state.sources.put(name, value) (handler.rs:1258).
  2. SimplePvStore::put: validator → apply under write lock → MDEL gate → in-process subscriber sends → returns changed (name, payload) list; on_put spawned; links evaluated (may append more changes).
  3. Handler's notify_changed_records (handler.rs:402) bumps the beacon counter and calls registry.notify_monitors per change.
  4. MonitorRegistry::notify_monitors (monitor.rs:102) builds per-subscriber frames — first frame full, later frames sparse delta (filtered subs) or full-or-suppressed (unfiltered, suppressed when unchanged) — respecting pipeline credits, and pushes bytes to each connection's sender.
  5. The connection's writer task writes to the socket.

Internal writes (scan, Pv::set, links) enter at step 2 via store.set_value and notify monitors from inside the store (the store holds the registry via set_registry). So there are two notification origins: protocol PUTs notify from the handler, internal writes from the store. Note the beacon change counter is only bumped on the protocol-PUT path.

Concurrency summary

Tokio tasks: UDP-search loop, TCP-accept loop, beacon loop, one per connection + one writer task per connection, one per .scan(), detached on_put tasks, one per group-subscription fan-in. Locks: store pvs (RwLock), monitor registry (Mutex), source registry (RwLock); each Pv handle's shared state is a std Mutex held only briefly, never across .await. Channels: per-connection outbound (128), per-subscriber NtPayload (64).

Record model and .db parsing

  • RecordType (types.rs:24): 17 kinds; from_db_name maps .db strings (mbbi|ntenum both → 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) — use nt_scalar_mut() (fallible) in generic code.
  • RecordInstance (types.rs:294) adds raw_fields: HashMap<String,String> (verbatim .db fields — 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-liner record(...){field(...)} silently drops its fields.
  • .db cannot load longin/longout/mbbi/mbbo/table/ndarray/generic — the two TODO(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

MechanismWhen it runsCan reject?Where registered
PUT validator (Pv::on_put)before applyyes (Err rejects on the wire)store.set_validator
on_put (classic builder)after apply, detached tokio::spawnno (fire-and-forget)SimplePvStore.on_put
scaninterval task calling store.set_valuen/aspawned in PvaServer::run
calc/linkevaluate_links after any input changesn/aLinkDef list

Alarms and deadbands

  • Alarm computation is NtScalar::update_alarm_from_value (spvirit-types/src/lib.rs:285), invoked only when compute_alarms is true — and compute_alarms defaults to false (server.rs:55).
  • Dual alarm-limit fields: NtScalar has alarm_low/high/lolo/hihi (Option<f64> — what the alarm engine reads) and value_alarm_*_limit (f64 — the NT wire metadata). The .db parser 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)

  1. 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_value types.rs:430–448, set_array_value/set_nt_payload types.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).
  2. PUT to Generic is not wired. RecordInstance::apply_put (apply.rs:639) returns false for Generic without looking at the PUT body. NtTable/NtNdArray dispatch to apply_table_put/ apply_ndarray_put (apply.rs:609–610) and are writable over the wire; Generic is writable only via put_nt.
  3. 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.
  4. group.rs::race_all (group.rs:575) polls members in vec order — first ready wins, lower indexes favoured; not starvation-proof.
  5. Default conn_timeout is ~64000 s (~17.8 h); the doc comment rounds it to 18 h (pva_server.rs:513).
  6. The beacon change counter only increments on protocol PUTs, not internal scan/set writes (handler.rs:108, 404).

Tests and examples

Tests are inline per-module; the biggest suites are in simple_store.rs (MDEL, timestamps, put/subscribe, all 12 array element types, put_nt, validator rejection), pv.rs (constructors, attach guards, ScalarValue preservation) and pva_server.rs (builder wiring, db_string, links, serve/bind). Run: cargo test -p spvirit-server.

19 runnable examples under spvirit-server/examples/ — from simple_server.rs up to snake.rs (a Snake game over PVAccess). The custom_pvstore / multi_source / wildcard_source / json_source / aggregate_source / passthrough_source / rpc_source set demonstrates the Source trait patterns; mailbox.rs is the p4p SharedPV equivalent.

spvirit-client & spvirit-tools — Client Library and CLI Tools

spvirit-client

Library-only crate (~4,900 LOC). Deps: spvirit-types, spvirit-codec, tokio, serde_json, dns-lookup, get_if_addrs, socket2, chrono. No TLS crate — TLS is the biggest known gap.

File~LOCPurpose
pva_client.rs970High-level API: PvaClient, PvaClientBuilder, PvaChannel (streaming PUT), monitor loop, pvinfo
search.rs1233UDP broadcast/multicast search + discovery, TCP name-server search, resolve_pv_server, EPICS env vars
pvlist.rs766PV-name listing with 4 fallback strategies (__pvlist, GET_FIELD, server RPC, server GET)
put_encode.rs673JSON→PVD PUT payload encoder (bitset partial encoding, scalars/arrays/unions/variants)
client.rs311Low-level channel lifecycle: establish_channel, pvget/pvget_fields
format.rs521Output rendering (text/JSON), NT metadata extraction, NTTable formatting
types.rs88PvOptions, PvGetResult, PvMonitorEvent, PvGetError
transport.rs61read_packet / read_until framed-packet readers
auth.rs25AuthNZ user/host resolution (options → env → "unknown")

Discovery and search (search.rs)

resolve_pv_server (search.rs:749) is the entry point: an explicit server_addr short-circuits; otherwise all strategies run concurrently in a JoinSet (one TCP name-server search per configured server + one UDP search), first success wins, rest aborted.

  • Targets: explicit --search-addr overrides everything; otherwise EPICS_PVA_ADDR_LIST entries + auto-broadcast targets (per-interface directed broadcast + IPv4 multicast 224.0.0.128 + IPv6 ff02::42:1 — multicast added because Docker overlay networks may block broadcast).
  • Retransmit schedule: 100/500/1000/2000 ms within the overall timeout.
  • Ephemeral source port is intentional (search.rs:376): binding the client to 5076 with SO_REUSEPORT loops packets back to the sender on Linux. Do not "fix" this.
  • SO_REUSEADDR is Unix-only (search.rs:321): deliberately skipped on Windows where the semantics are unsafe. Also do not "fix".

Operations

  • Channel setup (establish_channel, client.rs:56): TCP connect → learn version + byte order from the first packets → client validation (authnz "ca") → wait for ConnectionValidated → CREATE_CHANNEL (cid hardcoded to 1 — fine for one-shot connections, a hazard if you ever multiplex).
  • GET: GET INIT (subcmd 0x08, fixed 6-byte "all fields" pvRequest or encode_pv_request(fields)) → introspection from INIT → GET DATA → decode_with_field_desc. Result carries value: DecodedValue plus raw PVA and PVD bytes and the introspection.
  • PUT: defaults to field ["value"]; value is impl Into<serde_json::Value>, encoded by put_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_channel returns a PvaChannel for streaming puts (reuses introspection, echo keepalive after 10 s idle, background reader aborted on Drop).
  • MONITOR: INIT → START = 0x44 (START|GET). Callback FnMut(&DecodedValue) -> ControlFlow<()>; Break sends best-effort DESTROY. Read timeouts are non-fatal (continue); 10 s echo keepalive. Pipelining (MonitorOptions::pipelined(n)) encodes pipeline=true,queueSize=N in the pvRequest and appends queueSize to the INIT body and sets bit 0x80 on the INIT subcmd; ACKs at queueSize/2. Critical invariant (pva_client.rs:548–552): never set 0x80 on the START message — on non-INIT monitor messages 0x80 means "ACK with u32 body", and getting this wrong makes pvxs/Java drop the TCP connection.
  • INFO: GET_FIELD (cmd 0x11) → StructureDesc.

Environment variables

VariableEffect
EPICS_PVA_ADDR_LISTextra search targets
EPICS_PVA_AUTO_ADDR_LISTYES/NO auto-broadcast (default on)
EPICS_PVA_NAME_SERVERSTCP name servers (host[:port], port defaults 5075)
EPICS_PVA_ENABLE_GET_FIELD_FALLBACKopt-in pvlist GET_FIELD strategy
PVA_AUTHNZ_USER / USER / LOGNAME / USERNAMEauthnz user
PVA_AUTHNZ_HOST / HOSTNAME / HOST / COMPUTERNAMEauthnz 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): secondsPastEpoch is 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.
  • spinfo works 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.

BinaryLOCWhat it is / what it exercises
spget56One-shot GET; format_output, --raw hex dumps
spput316PUT; 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
spmonitor278Multi-PV monitor (JoinSet); --raw, --json, --pipeline N
spinfo192Introspection with the 3-level fallback chain
splist112Server discovery (GUID + addr) or PV listing via pvlist_with_fallback
spsine102Streaming-PUT sine generator (open_put_channel showcase)
spget_compare324Offline diagnostic: byte-compares captured frames against local encoder output; no network
spexplore1414ratatui TUI: servers→PVs→details panes, chart view, background worker thread over std::sync::mpsc
spsearch1094ratatui TUI: passive search-traffic sniffer; decodes frames directly with PvaPacket
spserver4185Full PVA server binary: .db loading, hot-reload, beacons, MDEL, __pvlist/discovery modes; record logic comes from the spvirit-server crate
spdodeca1179Self-contained single-PV server streaming a rotating dodecahedron as NTNDArray (does not use spvirit-server)
sptable~1200ratatui 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.

VerbShorthandArgsEffect
adda<name> <type> [ro|rw] <value>add PV(s)
sets<name> <value>set value (choice name or index for enum)
deld[name]delete (blank = selected row)
renamemv<old> <new>rename (scalar/enum)
ro/rw<name>set advertised access
anim<name> <gen> [k=v ...]animate
stop[name]stop animation (blank = selected)
sourceso<file>run a script of commands
writew<file>dump the session as a loadable script
rate<hz>retune the animation tick live (also --rate at startup)
help/quith/qshow 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.rs spawns workspace binaries; scenario_harness.rs gives connect/handshake/get/put helpers). Used by spvirit_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=1 and the external server is installed. See chapter 06.

demo/ directory (repo root, gitignored)

Archiver Appliance demo: archiver_demo_server.py (spvirit-py based, 18 PVs of every type incl. NTEnum/NTTable/NTNDArray), archiver_demo_gen.py (stdlib .db animator for spserver hot-reload), archiver_demo.db, Dockerfile (builds the wheel + runs the server). docker_compose.yml is a 0-byte placeholder. demo/README.md documents both run modes and warns that .db-reloaded arrays get a 1990-epoch timestamp the Archiver rejects.

spvirit-py — Python Bindings

PyO3 bindings exposing the client, server, typed PV handles, NT payloads, dynamic sources, and the low-level channel/codec to Python. Distributed on PyPI as spvirit; the complete user guide is spvirit-py/README.md (~32 KB, the primary user doc). This chapter covers the internals.

Packaging

  • Cargo.toml: package spvirit-py, versioned independently of the Rust crates (0.1.15 vs 0.1.18 — see chapter 07). [lib] name = "spvirit", crate-type = ["cdylib"]. pyo3 0.24 (extension-module, abi3-py39 — one wheel for Python 3.9+), pyo3-async-runtimes hard-pinned =0.24.0, tokio 1.47.
  • pyproject.toml: maturin backend, distribution name spvirit, version dynamic (from Cargo.toml).
  • There is no Python source package — the whole API is the compiled module plus two Rust-registered submodules spvirit.codec and spvirit.lowlevel (injected into sys.modules at import).
  • No .pyi type stubs — a known gap for a binding whose selling point is a typed API.
  • Stale artifacts present on disk but gitignored, not committed: dist/spvirit-0.1.9.tar.gz and .venv/ (both under .gitignore; the build/test commands assume .venv/ exists locally).

Dev loop:

cd C:\spvirit\spvirit-py
.\.venv\Scripts\maturin.exe develop          # debug build, importable in the venv
.\.venv\Scripts\python.exe tests\test_pv_handles.py

Module map (spvirit-py/src/, ~6,300 LOC)

FileLOCPurpose
lib.rs81#[pymodule] — the authoritative index of the public surface; registers classes, 16 factory functions, submodules; initializes the runtime bridge
runtime.rs64Shared Tokio runtime (LazyLock); block_on_py (releases GIL, re-entrancy aware via block_in_place); future_into_py (asyncio bridge)
errors.rs138Exception hierarchy under SpviritError; TimeoutError/IoError dual-inherit builtins
convert.rs605Value conversion layer, including the typed (strict) coercion layer (see below)
pv.rs997PyPv + PvKind, all factories, on_put/scan/calc bridging — the central file
server.rs835PyServerBuilder, PyServer, PyStore
client.rs595PyClientBuilder, PyClient, PySubscription, discovery
nt.rs740NT wrapper classes, NtPayload↔Python bridging
source.rs617Python-defined dynamic Sources: PyPvInfo, PyNotifier, PySourceAdapter, sync/async bridging
channel.rs635spvirit.lowlevel.Channel — persistent single-PV TCP connection
codec.rs492spvirit.codec — FieldDesc/StructureDesc wrappers, packet decode helpers
discovery.rs300spvirit.lowlevel search/discover/pvlist functions
packet.rs172spvirit.lowlevel.Packet — owned PVA frame

Threading model (read before touching)

Three design comments are the real documentation — read them first:

  1. runtime.rs:29–42 — one shared multi-thread Tokio runtime. block_on_py releases the GIL and detects being already on a runtime worker (uses block_in_place), so a Python callback running inside the runtime can call pv.set()/client ops without deadlock. Covered by test_on_put_can_set_other_pvs and test_client_usable_inside_callbacks.
  2. 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.
  3. source.rs:319–326async def source 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_complete deadlock.

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/Array wrapping the server crate's Pv<f64>/Pv<bool>/Pv<i32>/Pv<String>/PvArray, plus Typed(Pv<ScalarValue>, TypeCode) — a dynamically typed scalar covering all twelve NTScalar wire types, backing spvirit.scalar() and any long/unsigned handle minted by server.pv().
  • Factories: ai/ao/bi/bo/string_in/string_out/longin/longout generated by the pv_ctor! macro (pv.rs:604), each with keyword-only units/prec/desc/adel/mdel/drive_limits/alarm_limits; mbbi/mbbo hand-written; waveform/aai/aao build PvKind::Array and accept a keyword-only type= for the element type; scalar(name, initial, *, type, writable=False, **opts) covers all twelve NTScalar wire types via PvKind::Typed; calc bridges callback(list[float]) -> float; pv() infers record type from the initial value (bool checked before intisinstance(True, int) is True) or, with type=, picks the wire type explicitly.
  • Callbacks: on_put(pv, value) runs before apply; returning False or raising rejects the PUT on the wire; handle-driven pv.set() bypasses it. scan works as call or decorator; must be attached before serving (afterwards is a silent no-op — the core only logs a tracing warning). calc exceptions/non-float returns post 0.0 (asymmetric with scan's cache fallback — documented in the README).
  • Server: primary constructor Server(pvs=[...], sources=[...], **kwargs); run() blocks, start() spawns a std::thread running RUNTIME.block_on(server.run()). server.pv(name) sniffs the stored NT payload to mint a typed handle — it attaches to any served record, including long/unsigned/float-distinct ones, which now mint a dynamically typed PvKind::Typed handle (server.rs:583); it still raises KeyError for NTTable/NTNDArray/generic-structure records, which have no handle representation (use Store for those).
  • Store: name-keyed runtime access (get_value/get_nt/set_value/ set_array_value/put_nt/pv_names), all through block_on_py. set_value/ set_array_value/put_nt coerce strictly to the record's existing wire type and never retype a scalar or scalar-array record (put_nt on NtTable/NtNdArray replaces the payload wholesale instead).
  • Client: get/put/monitor/info/pvlist/subscribe; PySubscription runs on a spawned task, has is_active/error/close, context-manager support, and a Drop that aborts the task.
  • NT classes: NtScalar/NtScalarArray constructible with an optional type= picking the wire value type explicitly; NtTable(columns, *, labels=None, types=None, descriptor=None) and NtNdArray(value, dims, *, type=None) are now constructible too (previously read-only, returned only by Store.get_nt). Enum and Generic payloads cross the boundary as plain dicts (nt.rs:709, 716).
  • Dynamic sources: any duck-typed object with claim/get/put/names (+optional rpc/subscribe/on_start), sync or async. PvInfo.nt_scalar("double", writable) declares precise wire types via type strings — parsed by parse_type_code, now centralized in convert.rs (source.rs:42 imports it) and shared with the rest of the typed API.

The conversion layer

convert.rs has two parallel paths:

  • Inference path (unchanged, used when no type= is given): full-fidelity Rust → Python (every width preserved; U8 arrays become bytes), but lossy Python → Rustpy_to_scalar (convert.rs:199): bool→Bool, int→I64, float→F64, str→Str; py_to_scalar_array (convert.rs:221): bytes→U8, empty list → F64, otherwise sniffs the first element.
  • Typed (strict) path (convert.rs:411 onward — py_to_scalar_typed, py_to_scalar_array_typed, coerce_scalar_value, coerce_scalar_array_value): used whenever a type=/types= string is given, or when coercing against an existing record's wire type. Every Python value is checked strictly against the requested TypeCode: out-of-range raises OverflowError, wrong-kind raises TypeError, an unrecognized type string raises ValueError. This is what makes all twelve NTScalar wire types (byte/short/int32/ubyte/ushort/uint32/uint64/ float32/...) reachable from Python — see spvirit.scalar(), the type=/ types= kwargs across NT classes, factories, and ServerBuilder, and chapter 08 for how this landed.

Tests and examples

  • No Rust unit tests in this crate (it's a cdylib); the Rust-side tests for handle plumbing live in spvirit-server (cargo test -p spvirit-server pv::).
  • Python tests: tests/test_pv_handles.py (422 LOC) and tests/test_value_types.py (324 LOC, covers the typed-conversion layer: NtScalar/NtScalarArray/NtTable/NtNdArray type=/types=, spvirit.scalar(), server.pv() on long/unsigned records, strict coercion errors) — plain-assert scripts, not pytest; functions named test_* collected by a main() loop. Each server test uses a unique port pair (test_pv_handles.py: 15075–15206; test_value_types.py: 16060–16081, within the reserved 16060–16099 range). Run directly with the venv's python after maturin develop.
  • Examples: 24 scripts under spvirit-py/examples/ — start with demo_pv_handles.py (typed handles), demo_server.py, the demo_source_*.py family (dynamic sources), demo_channel*.py (low-level), demo_10k_farm.py/demo_stress.py (scale).

Testing Guide

The four layers

LayerWhereRun with
Rust unit testsinline #[cfg(test)] modules in every crate's src/cargo test --all
In-process protocol/integration testsspvirit-tools/tests/ (19 test files + shared harnesses, ~33 test fns)cargo test -p spvirit-tools
Cross-implementation interop testsspvirit-tools/tests/interop_*.rs + tests/interop/env-gated; see below
Python testsspvirit-py/tests/test_pv_handles.py + test_value_types.pyrun 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.rsTestServer/TestSession: spawns actual workspace binaries (locates target/<profile>/) and speaks raw frames.
  • scenario_harness.rsScenarioSession: 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:

SuiteNeedsEnable
p4p (pvxs)pip install p4pPVA_TEST_P4P=1, P4P_PROVIDER_CMDtests/interop/p4p_server.py, P4P_TEST_SERVER=127.0.0.1:5075
EPICS BaseEPICS Base installits own enable vars (see interop_epics_base.rs)
PVAccessJavaGradle projectsee interop_pvaccess_java.rs
Generic external serverany PVA serverPVA_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 Server uses 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 → expect ALL 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 — a None timestamp 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 uses dtolnay/rust-toolchain@stable and formatting is by convention (cargo fmt before committing; history shows manual style: 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 declares required-features.
  • target/package/spvirit-*-0.1.x/ directories are cargo package artifacts — never edit those copies.

CI (.github/workflows/ci.yml)

Runs on push and PR to main, two jobs on ubuntu-latest:

  1. build-and-testcargo build --release + cargo test --all.
  2. p4p-interop — installs p4p (Python 3.12), builds, runs the p4p provider matrix (interop_tool_matrix -- p4p_provider_matrix) with PVA_TEST_P4P=1 etc.

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 (the chore: Release commits) — 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-py versions independently (currently 0.1.15); the wheel version comes from its Cargo.toml via maturin dynamic = ["version"].
  • Release trigger: push a tag matching spvirit-py-v*.github/workflows/release-python.yml builds 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, environment pypi) — only on the tag push, not on manual dispatch.
  • PyPI package name: spvirit.

Release checklist (suggested)

  1. Working tree clean; cargo test --all green; Python suite green.
  2. Rust: bump all five crate versions + path-dep pins together; cargo release (or manual cargo publish in dependency order: types → codec → client/server → tools).
  3. Python: bump spvirit-py/Cargo.toml version, commit, tag spvirit-py-vX.Y.Z, push the tag, watch the workflow.
  4. 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 status before acting on anything here — this chapter goes stale fastest.

Repository state at handover

  • Branch main, 15 commits ahead of origin/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 (7c5bc9b through d85473b, latest fix(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_desc now includes descriptor, alarm, timeStamp fields; encode_nt_table_full encodes them (defaulting when None); new round-trip test nt_table_wire_format_carries_metadata. Encode order must match descriptor field order — that's the invariant.
  • types.rs: new RecordInstance::stamp_missing_timestamps() fills missing timestamps per payload family (NdArray stamps data_time_stamp too; Generic skipped).
  • simple_store.rs: calls it in SimplePvStore::new and insert, plus a test asserting all record families get seconds_past_epoch > 0 on store entry. Purely additive; caller-supplied timestamps are preserved.

Effort B — Python NT value-type selection (landed)

Delivered as a 10-task TDD plan with per-task commits.

Goal (achieved): let Python select any of the twelve NTScalar wire types instead of collapsing int→I64/float→F64. Architecture: shared type-string parser + strict coercion layer in spvirit-py/src/convert.rs (parse_scalar_type, py_to_scalar_typed, py_to_scalar_array_typed, coerce_scalar_value, coerce_scalar_array_value); keyword-only type=/ types= params across NT classes, factories, and ServerBuilder, and a new spvirit.scalar() factory backed by PvKind::Typed(Pv<ScalarValue>, TypeCode); store put paths coerce to the record's existing wire type. Errors: ValueError (unknown type string), OverflowError (out of range), TypeError (wrong kind) — matches spvirit-py/README.md, the authoritative user-facing doc.

Status: all 10 tasks are committed (git log 0819a18..d85473b). spvirit-py/tests/test_value_types.py (324 LOC, ports 16060–16081) now exists and exercises the full surface: NtScalar/NtScalarArray type=/.value_type, NtTable/NtNdArray constructors, spvirit.scalar(), server.pv() on long/unsigned records (no more KeyError), waveform/ aai/aao/pv() with type=, ServerBuilder type=/types= kwargs, and Store.set_value/set_array_value/put_nt strict coercion. Build: .\.venv\Scripts\maturin.exe develop; tests: cargo test -p spvirit-server pv:: and .\.venv\Scripts\python.exe tests\test_value_types.py.

Known gaps and latent bugs (triage list)

Cross-referenced from the per-crate chapters.

Protocol/codec

  • No TLS anywhere (top roadmap item in README).
  • No segmentation reassembly in the codec (consumers roll their own).
  • Monitor bitset overrun ordering is heuristic (three variants + scoring).
  • No packet-capture regression corpus; four duplicated size codecs.
  • Array decode caps silently truncate; string/struct truncation can desync.

Server

  • CANCEL_REQUEST unimplemented (some clients send it).
  • PUT to Generic not wired into RecordInstance::apply_put (only put_nt works); NtTable/NtNdArray PUTs are wired and restamp like any other record.
  • .db parser: one-statement-per-line only; cannot load longin/longout/mbbi/mbbo/table/ndarray/generic (the repo's only two TODO(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_alarms defaults 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.rs is 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.yml is an empty placeholder.

Python

  • No .pyi type 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 via type=/types= and spvirit.scalar(), with strict coercion. Remaining Python-side gaps: on_put/scan still unsupported on array PVs; widened byte/short/ int handles (the plain int PvKind) are not range-checked on write — only spvirit.scalar(type=...)/store.set_value enforce strict range checks on those types (see the README's "Widened int handles 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)

  1. TLS support in client (and eventually server).
  2. Structured put payloads surfaced in the high-level client API.
  3. More complete softIOC behaviours and record processing in the server (record types in .db, table/ndarray PUT, CANCEL_REQUEST).
  4. Finish the Python value-types work (Effort B) — done; see above.
  5. Quality infrastructure: packet-capture regression corpus, benchmarks, lint gate in CI, .pyi stubs.