Serving scalars
✅ Verified ·
scalar_metadata.rs·demo_scalars.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
The same two PVs as Your first PV, but described properly: engineering units, display precision, a description, alarm limits, drive limits, and a monitor deadband.
This is the difference between a number and a reading.
Rust
#![allow(unused)] fn main() { let temperature = Pv::ai("SIM:TEMPERATURE", 22.5) .units("degC") .prec(2) .desc("Sample block temperature") // lolo, low, high, hihi — MAJOR outside the outer pair, // MINOR outside the inner pair. .alarm_limits(0.0, 15.0, 30.0, 40.0) // Monitors stay quiet for changes smaller than this. .mdel(0.5); let setpoint = Pv::ao("SIM:SETPOINT", 25.0) .units("degC") .prec(1) .desc("Demanded temperature") .drive_limits(0.0, 100.0); // `scalar_out`/`scalar_in` pick the wire type from the `ScalarValue` // variant of `initial` — not from Rust's `u16`/`u8`, which have no // native `Pv<T>` handle of their own. This is the route to the eight // NTScalar types `ai`/`ao`/`bi`/`bo`/`longin`/`longout`/`string_in`/ // `string_out` don't cover. let gain = Pv::<ScalarValue>::scalar_out("SIM:GAIN", ScalarValue::U16(1)); let status = Pv::<ScalarValue>::scalar_in("SIM:STATUS", ScalarValue::U8(0)); let server = PvaServer::serve([ AnyPv::from(temperature.clone()), AnyPv::from(setpoint.clone()), AnyPv::from(gain.clone()), AnyPv::from(status.clone()), ]) .build() .await; }
Note what this is not using. PvaServer::builder().ai(name, value)
takes a name and an initial value and nothing else — there is no
.units() on the builder. Metadata is set through typed Pv<T> handles,
then handed to PvaServer::serve([...]). If you need metadata in code,
that is the route.
(The other route is a .db file, covered in
Loading .db files.)
Python
temperature = spvirit.ai(
"SIM:TEMPERATURE",
22.5,
units="degC",
prec=2,
desc="Sample block temperature",
# lolo, low, high, hihi
alarm_limits=(0.0, 15.0, 30.0, 40.0),
# Monitors stay quiet for changes smaller than this.
mdel=0.5,
)
setpoint = spvirit.ao(
"SIM:SETPOINT",
25.0,
units="degC",
prec=1,
desc="Demanded temperature",
drive_limits=(0.0, 100.0),
)
# `type=` picks the wire type by name (or alias, e.g. "u16"); `writable=True`
# serves the output flavor, `False` (default) the input flavor.
gain = spvirit.scalar("SIM:GAIN", 1, type="ushort", writable=True)
status = spvirit.scalar("SIM:STATUS", 0, type="byte")
server = spvirit.Server(pvs=[temperature, setpoint, gain, status])
server.run()
Python has no such split — spvirit.ai() takes all of it as keyword
arguments, and spvirit.Server(pvs=[...]) serves the handles.
What to notice
Metadata rides with the value. A client reading SIM:TEMPERATURE gets
degC and precision: 2 in the same message. That is the NTScalar
display structure from Normative Types,
and it is why a PVAccess GUI can label an axis without being told to.
Alarm limits are four numbers in one call, ordered outward-in:
alarm_limits(lolo, low, high, hihi). Crossing low or high is MINOR;
crossing lolo or hihi is MAJOR. Alarms goes into this.
Drive limits are advertised, not enforced. This one will catch you:
$ spput SIM:SETPOINT 500
SIM:SETPOINT OK
$ spget SIM:SETPOINT
SIM:SETPOINT 2026-08-04 10:00:31.426 500
drive_limits(0.0, 100.0) populates the NTScalar control structure so
clients know the intended range — and that is all it does. Nothing in the
server clamps a write. If out-of-range values must be rejected, reject
them yourself in an on_put handler; see
Reacting to writes.
MDEL and ADEL behave differently from the rest. They are written
into the record's field table rather than the NT payload, which means
they are the two pieces of metadata you can read back QSRV-style:
$ spget SIM:TEMPERATURE.MDEL
SIM:TEMPERATURE.MDEL 2026-08-04 10:02:48.038 0.5
$ spget SIM:TEMPERATURE.EGU
Error: Timeout("read header")
.EGU times out because there is no such channel. Field access serves the
dbCommon fields plus whatever a parsed .db file contained — and units
set in code goes into the payload, not the field table. The units are still
there; you just have to read the whole PV to see them. This is the gap
described in EPICS in 10 minutes.
Choosing the wire type
ai/ao/bi/bo/longin/longout/string_in/string_out (Rust) and
ai/ao/bo/longin/longout/string_in/string_out (Python) each
fix the NTScalar wire type to one of double, boolean, int or
string. PVAccess defines twelve NTScalar value types in total —
boolean, byte, short, int, long, their unsigned variants
(ubyte, ushort, uint, ulong), float, double, and string — and
reaching the other eight needs an explicit type selection.
Rust
#![allow(unused)] fn main() { // `scalar_out`/`scalar_in` pick the wire type from the `ScalarValue` // variant of `initial` — not from Rust's `u16`/`u8`, which have no // native `Pv<T>` handle of their own. This is the route to the eight // NTScalar types `ai`/`ao`/`bi`/`bo`/`longin`/`longout`/`string_in`/ // `string_out` don't cover. let gain = Pv::<ScalarValue>::scalar_out("SIM:GAIN", ScalarValue::U16(1)); let status = Pv::<ScalarValue>::scalar_in("SIM:STATUS", ScalarValue::U8(0)); }
Pv::<ScalarValue>::scalar_out/scalar_in build a record whose wire type
is whatever ScalarValue variant initial holds — scalar_out for a
writable PV, scalar_in for read-only.
Python
# `type=` picks the wire type by name (or alias, e.g. "u16"); `writable=True`
# serves the output flavor, `False` (default) the input flavor.
gain = spvirit.scalar("SIM:GAIN", 1, type="ushort", writable=True)
status = spvirit.scalar("SIM:STATUS", 0, type="byte")
spvirit.scalar(name, initial, *, type, writable=False, **opts) picks the
wire type by name (or alias, e.g. "u16"/"i32"); writable=True serves
the output flavor. The full type-name/alias table and the value coercion
rules (overflow, widening, narrowing) are in
spvirit-py's README, NT scalar type coverage
section
— the same reference the Python API page
points to for pv/scalar.
Run it
# Terminal 1
cargo run -p spvirit-server --example scalar_metadata
# or: python spvirit-py/examples/demo_scalars.py
# Terminal 2
spget SIM:TEMPERATURE
spget SIM:TEMPERATURE.MDEL
spput SIM:SETPOINT 30
spinfo SIM:GAIN
spinfo SIM:STATUS
Terminal 2 prints:
$ spget SIM:TEMPERATURE
SIM:TEMPERATURE 2026-08-06 09:13:06.435 22.5
$ spget SIM:TEMPERATURE.MDEL
SIM:TEMPERATURE.MDEL 2026-08-06 09:13:06.707 0.5
$ spput SIM:SETPOINT 30
SIM:SETPOINT OK
spinfo prints the type of every field. Only the third line differs
between the two records — SIM:GAIN is a ushort, SIM:STATUS a ubyte
— and the remaining forty-odd lines are the same NTScalar skeleton in both:
$ spinfo SIM:GAIN
SIM:GAIN:
struct epics:nt/NTScalar:1.0
value: ushort
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
display: structure
limitLow: double
limitHigh: double
description: string
units: string
precision: int
form: structure
index: int
choices: string[]
control: structure
limitLow: double
limitHigh: double
minStep: double
valueAlarm: structure
active: boolean
lowAlarmLimit: double
lowWarningLimit: double
highWarningLimit: double
highAlarmLimit: double
lowAlarmSeverity: int
lowWarningSeverity: int
highWarningSeverity: int
highAlarmSeverity: int
hysteresis: ubyte
$ spinfo SIM:STATUS
SIM:STATUS:
struct epics:nt/NTScalar:1.0
value: ubyte
...
That is the point of the narrow types: the wire carries two bytes for a
ushort and one for a ubyte, but the surrounding structure — alarm,
timestamp, display, control, valueAlarm — is identical whatever the value
type is.