Enums and binary records
✅ Verified ·
exotic_nt.rs·demo_enums.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
PVs whose value is one of a fixed set of named states — the EPICS mbbi
and mbbo records, carried on the wire as an NtEnum.
What an enum record is
An NtEnum value is not a string. It is a structure:
value:
index: int # which choice is selected
choices: string[] # the labels, in order
The record stores the index. The labels ride along so a client can
render "Running" without a separate lookup. This is why spget prints
{index=2, choices=["Idle", "Running", "Fault"]} rather than Fault.
| Record | Direction | EPICS field names for choices |
|---|---|---|
bi / bo | in / out | ZNAM, ONAM (exactly two) |
mbbi / mbbo | in / out | ZRST…FFST (up to sixteen) |
bi/bo are not NtEnum in spvirit — they are NtScalar booleans, and
spget prints true/false. ZNAM/ONAM name the two states in a .db
file. If you need the labels on the wire, use mbbi/mbbo.
Rust
#![allow(unused)] fn main() { let server = PvaServer::builder() .mbbi( "SIM:STATE", vec![ "Idle".to_string(), "Running".to_string(), "Error".to_string(), ], 0, ) .mbbo( "SIM:MODE", vec![ "Standby".to_string(), "Acquire".to_string(), "Calibrate".to_string(), ], 0, ) .generic( "SIM:POSITION", "demo:custom/Position:1.0", vec![ ("x".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))), ("y".to_string(), PvValue::Scalar(ScalarValue::F64(0.0))), ( "label".to_string(), PvValue::Scalar(ScalarValue::Str("origin".to_string())), ), ], ) .build(); }
Python
STATES = ["Idle", "Running", "Fault"]
MODES = ["Standby", "Acquire", "Calibrate"]
# mbbi is read-only over the wire; mbbo accepts client writes.
state = spvirit.mbbi("SIM:STATE", STATES, 0, desc="Machine state")
mode = spvirit.mbbo("SIM:MODE", MODES, 0, desc="Requested mode")
server = spvirit.Server(pvs=[state, mode])
server.start()
# The value is the choice *index*, not the label.
for i in range(len(STATES)):
state.set(i)
print(f"SIM:STATE = {i} ({STATES[i]})")
time.sleep(1)
What to notice
Enum records ignore the scalar metadata options. units, prec,
adel, mdel, and the limit setters do not apply — only desc is
accepted. An NtEnum has no engineering units to carry.
.db files cannot define enum records. mbbi and mbbo parse, then
fail at construction with:
Record 'DEMO:STATE': type 'mbbi' is not a standard EPICS Base record type
and cannot be loaded from .db files
The message is misleading — mbbi is a standard EPICS Base record type;
it is spvirit's .db loader that does not build it
(spvirit-server/src/db.rs:550). Build enum records in code.
Writing to an mbbo from a client does not work today. The record is
advertised writable and the PUT is accepted on the wire — spput prints
OK, the Python client's put() returns without raising — but the value
does not change:
$ spput SIM:MODE --json '{"value":{"index":2}}'
SIM:MODE OK
$ spget SIM:MODE
SIM:MODE {index=0, choices=["Standby", "Acquire", "Calibrate"]}
The store's enum PUT branch (spvirit-server/src/simple_store.rs:616)
matches only a bare integer under value, but the NTEnum wire format nests
the index one level deeper as value.index, so the update is dropped
silently. Drive enum records server-side with pv.set(index) and treat
them as read-only from the client until this is fixed.
Out-of-range indices are rejected, not clamped. The store checks
idx < 0 || idx >= choices.len() and leaves the value alone.
Run it
# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_enums.py
# Terminal 2
spget SIM:STATE
spmonitor SIM:STATE
$ spget SIM:STATE
SIM:STATE 2026-08-06 09:14:32.958 {index=0, choices=["Idle", "Running", "Error"]}
$ spinfo SIM:STATE
SIM:STATE:
struct epics:nt/NTEnum:1.0
value: structure
index: int
choices: string[]
alarm: structure
severity: int
status: int
message: string
timeStamp: structure
secondsPastEpoch: long
nanoseconds: int
userTag: int
value is a structure, not a number — that is what makes NTEnum
different from an NTScalar holding an integer.
Under a monitor the difference shows up on the wire:
$ spmonitor SIM:STATE
SIM:STATE 2026-08-06 09:27:05.724 {index=0, choices=["Idle", "Running", "Error"]}
SIM:STATE 2026-08-06 09:27:06.731 {index=1}
SIM:STATE 2026-08-06 09:27:07.748 {index=2}
SIM:STATE 2026-08-06 09:27:08.763 {index=0}
The choice list arrives once, in the first update, and is then omitted because it did not change. PVAccess sends a change bitset, not a whole structure. A client that only reads the field it was given on each update will lose the labels after the first one — cache them.